@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33

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 (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -40,8 +40,11 @@ import {
40
40
  } from './runtime.ts'
41
41
  import { validateBookingSurface } from './validation.ts'
42
42
  import { extractBookingDispatchReason, normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
43
+ import { tenantRejection, type BookingTenancy, type TurnScope } from './tenancy.ts'
44
+ import type { AuthenticatedCaller } from '../tenant-seam/seam.ts'
43
45
 
44
- export interface BookingCopilotComposition {
46
+ /** The single-ledger form: one runtime and one planner for every caller, the deployment's shared bearer key. */
47
+ export interface BookingSharedComposition {
45
48
  runtime: BookingCopilotTaskRuntime
46
49
  plannerFactory: BookingPlannerSessionFactory
47
50
  /** Optional BFF-owned identity binding. GoTry never accepts browser identity. */
@@ -50,11 +53,35 @@ export interface BookingCopilotComposition {
50
53
  principal?: BookingIngressPrincipal
51
54
  /** Explicit process composition mode; omitted means infer from a complete pair. */
52
55
  ingressMode?: BookingCopilotIngressMode
56
+ tenancy?: undefined
53
57
  }
54
58
 
55
- export interface BookingCopilotServerOptions extends BookingCopilotComposition {
56
- /** Deployment credential shared only with the HotelByte BFF. */
57
- apiKey: string
59
+ /**
60
+ * The multi-tenant form (AaaS): each request is authenticated, placed in its own tenant at the seam and served from
61
+ * that tenant's own guarded ledger. There is no shared runtime, planner or principal to fall back on.
62
+ */
63
+ export interface BookingTenantComposition {
64
+ tenancy: BookingTenancy
65
+ ingressMode?: BookingCopilotIngressMode
66
+ runtime?: undefined
67
+ plannerFactory?: undefined
68
+ ingressBinding?: undefined
69
+ principal?: undefined
70
+ }
71
+
72
+ export type BookingCopilotComposition = BookingSharedComposition | BookingTenantComposition
73
+
74
+ /**
75
+ * The shared form needs the deployment key (a missing one is a compile error, as it always was); the tenant form takes
76
+ * none and authenticates through its tenancy (`apiKey?: never`).
77
+ */
78
+ export type BookingCopilotServerOptions = (
79
+ | (BookingSharedComposition & {
80
+ /** Deployment credential shared only with the HotelByte BFF. */
81
+ apiKey: string
82
+ })
83
+ | (BookingTenantComposition & { apiKey?: never })
84
+ ) & {
58
85
  host?: string
59
86
  port?: number
60
87
  maxBodyBytes?: number
@@ -77,7 +104,8 @@ export interface BookingCopilotRuntimeIdentity {
77
104
 
78
105
  /** gotry-backend 模块挂载用的流量处理依赖(startBookingCopilotServer 与 backend 模块共用) */
79
106
  export interface BookingCopilotTrafficDeps {
80
- apiKey: string
107
+ /** The shared form's bearer key. Unused by the tenant form, which authenticates through its tenancy. */
108
+ apiKey?: string
81
109
  composition: BookingCopilotComposition
82
110
  maxBodyBytes: number
83
111
  artifactId?: string
@@ -98,8 +126,21 @@ export function bookingCopilotTrafficHandler(deps: BookingCopilotTrafficDeps): (
98
126
  sendJson(res, 404, { error: { code: 'not_found' } })
99
127
  return
100
128
  }
101
- const auth = String(req.headers.authorization ?? '')
102
- if (!safeSecretEqual(auth, `Bearer ${apiKey}`)) {
129
+ // The one place a credential is read. The tenant form asks its authenticator, which names the caller; the shared
130
+ // form compares the deployment key as before. Neither reads a tenant from the request.
131
+ let caller: AuthenticatedCaller | null = null
132
+ if (composition.tenancy) {
133
+ try {
134
+ caller = await composition.tenancy.authenticate({ method: req.method ?? '', path: (req.url ?? '').split('?')[0] ?? '', headers: req.headers })
135
+ } catch {
136
+ sendJson(res, 503, { error: { code: 'authentication_unavailable' } })
137
+ return
138
+ }
139
+ if (!caller) {
140
+ sendJson(res, 401, { error: { code: 'unauthorized' } })
141
+ return
142
+ }
143
+ } else if (!apiKey || !safeSecretEqual(String(req.headers.authorization ?? ''), `Bearer ${apiKey}`)) {
103
144
  sendJson(res, 401, { error: { code: 'unauthorized' } })
104
145
  return
105
146
  }
@@ -154,7 +195,7 @@ export function bookingCopilotTrafficHandler(deps: BookingCopilotTrafficDeps): (
154
195
  const lifecycle = lifecycleFor(composition)
155
196
  lifecycle.acceptedRequests += 1
156
197
  try {
157
- await handleBookingCopilotRequest(req, res, composition, maxBodyBytes, negotiatedBookingSurfaceFeatures(req))
198
+ await handleBookingCopilotRequest(req, res, composition, maxBodyBytes, negotiatedBookingSurfaceFeatures(req), caller)
158
199
  } finally {
159
200
  finishAcceptedRequest(lifecycle)
160
201
  }
@@ -230,18 +271,18 @@ function schedulePlannerSessionClose(composition: BookingCopilotComposition, ses
230
271
  }
231
272
 
232
273
  /** Detach a session from the request path and close it in the composition lifecycle. */
233
- function releasePlannerSession(composition: BookingCopilotComposition, taskId: string): void {
274
+ function releasePlannerSession(composition: BookingCopilotComposition, sessionKey: string): void {
234
275
  const sessions = sessionsFor(composition)
235
- const session = sessions.get(taskId)
276
+ const session = sessions.get(sessionKey)
236
277
  if (!session) return
237
- sessions.delete(taskId)
278
+ sessions.delete(sessionKey)
238
279
  schedulePlannerSessionClose(composition, session)
239
280
  }
240
281
 
241
282
  async function drainPlannerSessions(composition: BookingCopilotComposition): Promise<void> {
242
283
  const lifecycle = lifecycleFor(composition)
243
- for (const [taskId, session] of lifecycle.active) {
244
- lifecycle.active.delete(taskId)
284
+ for (const [sessionKey, session] of lifecycle.active) {
285
+ lifecycle.active.delete(sessionKey)
245
286
  schedulePlannerSessionClose(composition, session)
246
287
  }
247
288
  while (lifecycle.closing.size > 0) {
@@ -458,7 +499,7 @@ function fallbackErrorEvent(task: BookingCopilotTaskState, code: string): Bookin
458
499
  }
459
500
  }
460
501
 
461
- function writeTypedError(res: ServerResponse, composition: BookingCopilotComposition, task: BookingCopilotTaskState, error: unknown, requestKey?: string, includeSubmitted = false): void {
502
+ function writeTypedError(res: ServerResponse, runtime: BookingCopilotTaskRuntime, task: BookingCopilotTaskState, error: unknown, requestKey?: string, includeSubmitted = false, isTenantFault: (error: unknown) => boolean = () => false): void {
462
503
  const code = normalizeBookingErrorCode(error)
463
504
  // Provider/adapter exceptions can contain response bodies, URLs or request
464
505
  // context. Preserve only the closed code and coarse thrown-value class.
@@ -469,25 +510,75 @@ function writeTypedError(res: ServerResponse, composition: BookingCopilotComposi
469
510
  try {
470
511
  const decision = { kind: 'error' as const, error: { code, message: safeBookingErrorMessage(code), retryable: false } }
471
512
  if (requestKey) {
472
- const events = composition.runtime.applyDecisionBatch(task.taskId, requestKey, [decision], includeSubmitted, { suppressProgressStatuses: true })
513
+ const events = runtime.applyDecisionBatch(task.taskId, requestKey, [decision], includeSubmitted, { suppressProgressStatuses: true })
473
514
  for (const event of events) write(res, event)
474
515
  return
475
516
  }
476
- write(res, composition.runtime.emitEvent(task.taskId, decision))
477
- } catch {
517
+ write(res, runtime.emitEvent(task.taskId, decision))
518
+ } catch (writeError) {
519
+ // A lease lost under the durable write is not a planner answer: nothing can be acknowledged, so the caller cuts the connection.
520
+ if (isTenantFault(writeError)) throw writeError
478
521
  // The stream is already committed. Emit a schema-valid, non-durable
479
522
  // typed envelope instead of silently returning an empty HTTP 200 stream.
480
523
  write(res, fallbackErrorEvent(task, code))
481
524
  }
482
525
  }
483
526
 
484
- /** Handles one already-authenticated request inside the sole HTTP listener. */
527
+ /** The shared form's scope: the one runtime and planner of the composition, keys exactly as they were before tenants existed. */
528
+ function sharedScope(composition: BookingSharedComposition): TurnScope {
529
+ const ingress = composition.ingressMode === 'bff-ingress-binding' && composition.ingressBinding && composition.principal
530
+ ? { binding: composition.ingressBinding, principal: composition.principal }
531
+ : null
532
+ return {
533
+ key: null,
534
+ runtime: composition.runtime,
535
+ plannerFactory: composition.plannerFactory,
536
+ ingress,
537
+ sessionKey: (taskId) => taskId,
538
+ flightKey: (parts) => JSON.stringify(parts),
539
+ runDecision: (work) => work(),
540
+ isTenantFault: () => false,
541
+ release: () => undefined,
542
+ }
543
+ }
544
+
545
+ /** Handles one already-authenticated request inside the sole HTTP listener: places it in its scope, then serves it there. */
485
546
  async function handleBookingCopilotRequest(
486
547
  req: IncomingMessage,
487
548
  res: ServerResponse,
488
549
  composition: BookingCopilotComposition,
489
550
  maxBodyBytes: number,
490
551
  negotiatedFeatures: readonly BookingSurfaceFeature[],
552
+ caller: AuthenticatedCaller | null,
553
+ ): Promise<void> {
554
+ let scope: TurnScope
555
+ if (composition.tenancy) {
556
+ // Derive the tenant, admit the turn against its quotas and open its guarded ledger, all before the body is read.
557
+ try {
558
+ scope = await composition.tenancy.beginTurn(caller!)
559
+ } catch (error) {
560
+ const rejection = tenantRejection(error)
561
+ if (rejection.retryAfterSeconds !== undefined) res.setHeader('retry-after', String(rejection.retryAfterSeconds))
562
+ sendJson(res, rejection.status, rejection.body)
563
+ return
564
+ }
565
+ } else {
566
+ scope = sharedScope(composition)
567
+ }
568
+ try {
569
+ await serveBookingTurn(req, res, composition, scope, maxBodyBytes, negotiatedFeatures)
570
+ } finally {
571
+ scope.release()
572
+ }
573
+ }
574
+
575
+ async function serveBookingTurn(
576
+ req: IncomingMessage,
577
+ res: ServerResponse,
578
+ composition: BookingCopilotComposition,
579
+ scope: TurnScope,
580
+ maxBodyBytes: number,
581
+ negotiatedFeatures: readonly BookingSurfaceFeature[],
491
582
  ): Promise<void> {
492
583
  let turn: Exclude<BookingCopilotTurn, IngressTurn>
493
584
  let browserRequestKey: string | undefined
@@ -500,11 +591,11 @@ async function handleBookingCopilotRequest(
500
591
  if ((parsed as BookingCopilotTurn).kind === 'user.turn.ingress') {
501
592
  const ingress = parsed as IngressTurn
502
593
  browserRequestKey = ingress.requestKey
503
- if (composition.ingressMode !== 'bff-ingress-binding' || !composition.ingressBinding || !composition.principal) {
594
+ if (!scope.ingress) {
504
595
  sendJson(res, 503, { error: { code: 'trusted_ingress_binding_required', mode: composition.ingressMode ?? 'bff-bound-turn-only', acceptedTurnKinds: [...BOOKING_COPILOT_ACCEPTED_TURN_KINDS] } })
505
596
  return
506
597
  }
507
- const binding = await composition.ingressBinding.bind(ingress, composition.principal)
598
+ const binding = await scope.ingress.binding.bind(ingress, scope.ingress.principal)
508
599
  if (!validIngressBinding(binding, ingress)) { sendJson(res, 502, { error: { code: 'invalid_ingress_binding' } }); return }
509
600
  if (!validIngressApprovalAuthority(ingress, binding)) { sendJson(res, 400, { error: { code: 'invalid_ingress_approval_authority' } }); return }
510
601
  const proposedBoundWorkspace = {
@@ -518,7 +609,7 @@ async function handleBookingCopilotRequest(
518
609
  ? { text: ingress.request.text, approval: { ...ingress.request.approval } }
519
610
  : { text: ingress.request.text }
520
611
  turn = { schemaVersion: BOOKING_SURFACE_SCHEMA_VERSION, kind: 'user.turn', taskId: binding.taskId, turnId: binding.turnId, workspace: proposedBoundWorkspace, request: boundRequest }
521
- requestBinding = { requestKey: ingress.requestKey, principal: composition.principal, ...(ingress.taskHandle ? { taskHandle: ingress.taskHandle } : {}) }
612
+ requestBinding = { requestKey: ingress.requestKey, principal: scope.ingress.principal, ...(ingress.taskHandle ? { taskHandle: ingress.taskHandle } : {}) }
522
613
  authorityFailureStatus = 502
523
614
  const boundValid = validateBookingSurface(turn)
524
615
  if (!boundValid.ok) { sendJson(res, 502, { error: { code: 'invalid_ingress_binding', details: boundValid.errors } }); return }
@@ -534,7 +625,7 @@ async function handleBookingCopilotRequest(
534
625
 
535
626
  let task: BookingCopilotTaskState; let fresh = false; let replayKey: string | undefined; let replayEvents: BookingSurfaceEvent[] | null = null; let activeDecisionKey: string | undefined
536
627
  try {
537
- const existingAtAdmission = turn.taskId ? composition.runtime.resumeTask(turn.taskId) : null
628
+ const existingAtAdmission = turn.taskId ? scope.runtime.resumeTask(turn.taskId) : null
538
629
  const narrowedWorkspace = narrowWorkspaceOptionalAuthority(turn.workspace, negotiatedFeatures, existingAtAdmission?.allowedActions)
539
630
  if (!narrowedWorkspace) {
540
631
  const code = authorityFailureStatus === 502 ? 'invalid_ingress_binding' : 'invalid_bound_turn_authority'
@@ -544,29 +635,29 @@ async function handleBookingCopilotRequest(
544
635
  turn = { ...turn, workspace: narrowedWorkspace }
545
636
  fresh = turn.kind === 'action.receipt.continuation' ? false : existingAtAdmission === null
546
637
  if (turn.kind === 'action.receipt.continuation') {
547
- replayKey = `receipt:${turn.receipt.actionId}:${composition.runtime.receiptDigest(turn.receipt)}`
548
- task = composition.runtime.continueWithReceipt(turn)
638
+ replayKey = `receipt:${turn.receipt.actionId}:${scope.runtime.receiptDigest(turn.receipt)}`
639
+ task = scope.runtime.continueWithReceipt(turn)
549
640
  if (task.awaitingApproval) {
550
- replayKey = composition.runtime.approvalPresentationRequestKey(turn.taskId)
641
+ replayKey = scope.runtime.approvalPresentationRequestKey(turn.taskId)
551
642
  }
552
- replayEvents = composition.runtime.readDecisionBatch(turn.taskId, replayKey)
553
- if (!replayEvents && task.phase === 'terminal') replayEvents = composition.runtime.terminalDecisionBatch(turn.taskId, replayKey)
643
+ replayEvents = scope.runtime.readDecisionBatch(turn.taskId, replayKey)
644
+ if (!replayEvents && task.phase === 'terminal') replayEvents = scope.runtime.terminalDecisionBatch(turn.taskId, replayKey)
554
645
  } else {
555
646
  const existing = existingAtAdmission
556
- if (requestBinding && turn.kind === 'user.turn') composition.runtime.assertRequestBinding(browserRequestKey!, turn, requestBinding)
647
+ if (requestBinding && turn.kind === 'user.turn') scope.runtime.assertRequestBinding(browserRequestKey!, turn, requestBinding)
557
648
  const requestReplayKey = existing && browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : undefined
558
649
  const directReplayKey = existing && turn.kind === 'user.turn' && turn.turnId ? `turn:${existing.taskId}:${turn.turnId}` : undefined
559
650
  const stableReplayKey = requestReplayKey ?? directReplayKey
560
- const stableReplay = stableReplayKey ? composition.runtime.readDecisionBatch(existing!.taskId, stableReplayKey) : null
651
+ const stableReplay = stableReplayKey ? scope.runtime.readDecisionBatch(existing!.taskId, stableReplayKey) : null
561
652
  if (stableReplay) {
562
- if (existing && turn.kind === 'user.turn' && directReplayKey) composition.runtime.assertTurnBinding(existing.taskId, turn)
653
+ if (existing && turn.kind === 'user.turn' && directReplayKey) scope.runtime.assertTurnBinding(existing.taskId, turn)
563
654
  task = existing!
564
655
  replayKey = stableReplayKey
565
656
  replayEvents = stableReplay
566
657
  } else if (existing?.phase === 'terminal' || existing?.phase === 'error') {
567
658
  throw new Error('task_terminal')
568
659
  } else if (existing?.phase === 'waiting_receipt') {
569
- if (turn.kind === 'user.turn' && turn.request.approval) composition.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval)
660
+ if (turn.kind === 'user.turn' && turn.request.approval) scope.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval)
570
661
  const workspace = turn.workspace as BookingWorkspaceSnapshot
571
662
  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')
572
663
  // A waiting task can only be replayed with the caller's stable turn
@@ -574,19 +665,25 @@ async function handleBookingCopilotRequest(
574
665
  // from a new user turn and must not receive an old operation batch.
575
666
  if (!turn.turnId || existing.lastTurnId !== turn.turnId) throw new Error('receipt_required')
576
667
  replayKey = browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : `turn:${existing.taskId}:${existing.lastTurnId ?? existing.userTurnCount}`
577
- replayEvents = composition.runtime.readDecisionBatch(existing.taskId, replayKey)
668
+ replayEvents = scope.runtime.readDecisionBatch(existing.taskId, replayKey)
578
669
  if (!replayEvents) throw new Error('receipt_required')
579
670
  task = existing
580
671
  } else {
581
- if (existing && turn.kind === 'user.turn' && turn.request.approval) composition.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval)
582
- task = composition.runtime.startTask(turn, requestBinding)
672
+ if (existing && turn.kind === 'user.turn' && turn.request.approval) scope.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval)
673
+ task = scope.runtime.startTask(turn, requestBinding)
583
674
  if (turn.kind === 'user.turn' && turn.taskId) {
584
675
  replayKey = browserRequestKey ? `ingress:${task.taskId}:${browserRequestKey}` : `turn:${task.taskId}:${turn.turnId ?? task.userTurnCount}`
585
- replayEvents = composition.runtime.readDecisionBatch(task.taskId, replayKey)
676
+ replayEvents = scope.runtime.readDecisionBatch(task.taskId, replayKey)
586
677
  }
587
678
  }
588
679
  }
589
680
  } catch (error) {
681
+ if (scope.isTenantFault(error)) {
682
+ const rejection = tenantRejection(error)
683
+ if (rejection.retryAfterSeconds !== undefined) res.setHeader('retry-after', String(rejection.retryAfterSeconds))
684
+ sendJson(res, rejection.status, rejection.body)
685
+ return
686
+ }
590
687
  const code = normalizeBookingErrorCode(error)
591
688
  console.error(JSON.stringify({ code, reason: extractBookingDispatchReason(error) }))
592
689
  sendJson(res, 409, { error: { code } }); return
@@ -596,13 +693,13 @@ async function handleBookingCopilotRequest(
596
693
  res.flushHeaders()
597
694
  try {
598
695
  if (replayEvents) {
599
- const progressEvents = composition.runtime.readProgressBatch(task.taskId, replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`) ?? []
696
+ const progressEvents = scope.runtime.readProgressBatch(task.taskId, replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`) ?? []
600
697
  for (const event of progressEvents) write(res, event)
601
698
  if (progressEvents.length) {
602
699
  flush(res)
603
700
  await new Promise<void>((resolve) => setImmediate(resolve))
604
701
  }
605
- if (task.phase === 'terminal' || task.phase === 'error') releasePlannerSession(composition, task.taskId)
702
+ if (task.phase === 'terminal' || task.phase === 'error') releasePlannerSession(composition, scope.sessionKey(task.taskId))
606
703
  for (const event of replayEvents) write(res, event)
607
704
  } else {
608
705
  const requestKey = replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`
@@ -611,34 +708,37 @@ async function handleBookingCopilotRequest(
611
708
  // committed progress but before its planner flight finishes. Re-serve
612
709
  // the durable progress batch so it receives the same prefix instead of
613
710
  // exposing a terminal-only tail.
614
- const newlyPersistedProgress = composition.runtime.ensureProgressBatch(task.taskId, requestKey, fresh)
711
+ const newlyPersistedProgress = scope.runtime.ensureProgressBatch(task.taskId, requestKey, fresh)
615
712
  const progressEvents = newlyPersistedProgress.length > 0
616
713
  ? newlyPersistedProgress
617
- : (composition.runtime.readProgressBatch(task.taskId, requestKey) ?? [])
714
+ : (scope.runtime.readProgressBatch(task.taskId, requestKey) ?? [])
618
715
  const approval = turn.kind === 'user.turn' ? turn.request.approval : undefined
619
- const flightKey = JSON.stringify([task.taskId, approval ? `approval:${bookingDigest(approval)}` : requestKey])
716
+ const flightKey = scope.flightKey([task.taskId, approval ? `approval:${bookingDigest(approval)}` : requestKey])
620
717
  // Register or join the decision flight before yielding after the progress
621
718
  // flush. A duplicate that observed the durable progress must never miss
622
719
  // an about-to-finish flight and pay for a second model invocation.
623
- const decisionOutcome = runDecisionSingleFlight(composition, flightKey, async () => {
720
+ // The decision work runs inside the scope: in the tenant form that proves the lease before and after it, lets a
721
+ // handoff drain it, and keeps its events from reaching the client if the lease was lost meanwhile.
722
+ const decisionOutcome = runDecisionSingleFlight(composition, flightKey, () => scope.runDecision(async () => {
624
723
  // Cover the complementary late-join case: the prior flight may have
625
724
  // committed its exact batch and left the in-memory map between request
626
725
  // admission and this work starting.
627
- const durableDecision = composition.runtime.readDecisionBatch(task.taskId, requestKey)
726
+ const durableDecision = scope.runtime.readDecisionBatch(task.taskId, requestKey)
628
727
  if (durableDecision) return durableDecision
629
728
  if (turn.kind === 'action.receipt.continuation' && task.awaitingApproval) {
630
- return composition.runtime.applyDecisionBatch(task.taskId, requestKey, [composition.runtime.approvalQuestion(task.taskId)], false)
729
+ return scope.runtime.applyDecisionBatch(task.taskId, requestKey, [scope.runtime.approvalQuestion(task.taskId)], false)
631
730
  }
632
731
  const sessions = sessionsFor(composition)
633
- const session = sessions.get(task.taskId) ?? composition.plannerFactory(task)
634
- sessions.set(task.taskId, session)
635
- const decisions = await session.next({ turn, task: composition.runtime.resumeTask(task.taskId) ?? task })
732
+ const sessionKey = scope.sessionKey(task.taskId)
733
+ const session = sessions.get(sessionKey) ?? scope.plannerFactory(task)
734
+ sessions.set(sessionKey, session)
735
+ const decisions = await session.next({ turn, task: scope.runtime.resumeTask(task.taskId) ?? task })
636
736
  const plannerDecisions = decisions.length ? decisions : [{ kind: 'error' as const, error: { code: 'PLANNER_NO_DECISION', message: 'Planner returned no typed decision.', retryable: false } }]
637
- const events = composition.runtime.applyDecisionBatch(task.taskId, requestKey, plannerDecisions, fresh, { suppressProgressStatuses: true })
638
- const updated = composition.runtime.resumeTask(task.taskId)
639
- if (updated?.phase === 'terminal' || updated?.phase === 'error') releasePlannerSession(composition, task.taskId)
737
+ const events = scope.runtime.applyDecisionBatch(task.taskId, requestKey, plannerDecisions, fresh, { suppressProgressStatuses: true })
738
+ const updated = scope.runtime.resumeTask(task.taskId)
739
+ if (updated?.phase === 'terminal' || updated?.phase === 'error') releasePlannerSession(composition, sessionKey)
640
740
  return events
641
- }).then(
741
+ })).then(
642
742
  (events) => ({ ok: true as const, events }),
643
743
  (error: unknown) => ({ ok: false as const, error }),
644
744
  )
@@ -652,58 +752,89 @@ async function handleBookingCopilotRequest(
652
752
  for (const event of outcome.events) write(res, event)
653
753
  }
654
754
  } catch (error) {
655
- writeTypedError(res, composition, task, error, activeDecisionKey, fresh)
656
- const updated = composition.runtime.resumeTask(task.taskId)
657
- if (updated?.phase === 'terminal' || updated?.phase === 'error') releasePlannerSession(composition, task.taskId)
755
+ // A lease lost, fenced or handed off while the stream is open cannot be told to the client as an answer: nothing
756
+ // was acknowledged, so the connection is cut and the BFF retries the same request key on the next owner.
757
+ if (scope.isTenantFault(error)) { res.destroy(); return }
758
+ try {
759
+ writeTypedError(res, scope.runtime, task, error, activeDecisionKey, fresh, scope.isTenantFault)
760
+ const updated = scope.runtime.resumeTask(task.taskId)
761
+ if (updated?.phase === 'terminal' || updated?.phase === 'error') releasePlannerSession(composition, scope.sessionKey(task.taskId))
762
+ } catch (faultWhileAnswering) {
763
+ // The lease went while the planner's error was being recorded: the same rule as above, and never an escaped rejection.
764
+ if (!scope.isTenantFault(faultWhileAnswering)) throw faultWhileAnswering
765
+ res.destroy()
766
+ }
658
767
  } finally { res.end() }
659
768
  }
660
769
 
661
770
  export function startBookingCopilotServer(options: BookingCopilotServerOptions): Promise<BookingCopilotServerHandle> {
662
- if (!options.apiKey) return Promise.reject(new Error('booking_copilot_api_key_required'))
771
+ // The shared form's key is checked first, exactly as before the tenant form existed.
772
+ if (!options.tenancy && !options.apiKey) return Promise.reject(new Error('booking_copilot_api_key_required'))
663
773
  if (options.artifactId !== undefined && !/^[0-9a-f]{40}$/.test(options.artifactId)) {
664
774
  return Promise.reject(new Error('booking_copilot_artifact_id_invalid'))
665
775
  }
666
- if (!options.runtime || !options.plannerFactory) {
667
- return Promise.reject(new Error('booking_copilot_runtime_and_planner_required'))
668
- }
669
- const hasIngressBinding = Boolean(options.ingressBinding)
670
- const hasPrincipal = Boolean(options.principal)
671
- if (hasIngressBinding !== hasPrincipal) {
672
- return Promise.reject(new Error('booking_copilot_ingress_binding_pair_required'))
673
- }
674
- if (hasIngressBinding && (typeof options.ingressBinding?.bind !== 'function'
675
- || typeof options.principal?.subject !== 'string' || options.principal.subject.length === 0 || options.principal.subject.length > 256
676
- || typeof options.principal?.scope !== 'string' || options.principal.scope.length === 0 || options.principal.scope.length > 256)) {
677
- return Promise.reject(new Error('booking_copilot_ingress_binding_invalid'))
678
- }
679
- const ingressMode: BookingCopilotIngressMode = options.ingressMode ?? (hasIngressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only')
680
- if (!BOOKING_COPILOT_INGRESS_MODES.includes(ingressMode)) {
681
- return Promise.reject(new Error('booking_copilot_ingress_mode_invalid'))
682
- }
683
- if (ingressMode === 'bff-ingress-binding' && !hasIngressBinding) {
684
- return Promise.reject(new Error('booking_copilot_ingress_binding_required'))
685
- }
686
- if (ingressMode === 'bff-bound-turn-only' && hasIngressBinding) {
687
- return Promise.reject(new Error('booking_copilot_ingress_mode_conflict'))
688
- }
689
- const composition: BookingCopilotComposition = {
690
- runtime: options.runtime,
691
- plannerFactory: options.plannerFactory,
692
- ...(hasIngressBinding ? { ingressBinding: options.ingressBinding, principal: options.principal } : {}),
693
- ingressMode,
776
+ let composition: BookingCopilotComposition
777
+ let ingressMode: BookingCopilotIngressMode
778
+ if (options.tenancy) {
779
+ // One authentication system per form: the tenant form has no shared key and no shared runtime, planner or principal.
780
+ if (options.apiKey) return Promise.reject(new Error('booking_copilot_tenancy_api_key_conflict'))
781
+ if (options.runtime || options.plannerFactory || options.ingressBinding || options.principal) {
782
+ return Promise.reject(new Error('booking_copilot_tenancy_shared_conflict'))
783
+ }
784
+ ingressMode = options.tenancy.ingressMode
785
+ if (options.ingressMode !== undefined && options.ingressMode !== ingressMode) {
786
+ return Promise.reject(new Error('booking_copilot_ingress_mode_conflict'))
787
+ }
788
+ composition = { tenancy: options.tenancy, ingressMode }
789
+ } else {
790
+ if (!options.runtime || !options.plannerFactory) {
791
+ return Promise.reject(new Error('booking_copilot_runtime_and_planner_required'))
792
+ }
793
+ const hasIngressBinding = Boolean(options.ingressBinding)
794
+ const hasPrincipal = Boolean(options.principal)
795
+ if (hasIngressBinding !== hasPrincipal) {
796
+ return Promise.reject(new Error('booking_copilot_ingress_binding_pair_required'))
797
+ }
798
+ if (hasIngressBinding && (typeof options.ingressBinding?.bind !== 'function'
799
+ || typeof options.principal?.subject !== 'string' || options.principal.subject.length === 0 || options.principal.subject.length > 256
800
+ || typeof options.principal?.scope !== 'string' || options.principal.scope.length === 0 || options.principal.scope.length > 256)) {
801
+ return Promise.reject(new Error('booking_copilot_ingress_binding_invalid'))
802
+ }
803
+ ingressMode = options.ingressMode ?? (hasIngressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only')
804
+ if (!BOOKING_COPILOT_INGRESS_MODES.includes(ingressMode)) {
805
+ return Promise.reject(new Error('booking_copilot_ingress_mode_invalid'))
806
+ }
807
+ if (ingressMode === 'bff-ingress-binding' && !hasIngressBinding) {
808
+ return Promise.reject(new Error('booking_copilot_ingress_binding_required'))
809
+ }
810
+ if (ingressMode === 'bff-bound-turn-only' && hasIngressBinding) {
811
+ return Promise.reject(new Error('booking_copilot_ingress_mode_conflict'))
812
+ }
813
+ composition = {
814
+ runtime: options.runtime,
815
+ plannerFactory: options.plannerFactory,
816
+ ...(hasIngressBinding ? { ingressBinding: options.ingressBinding, principal: options.principal } : {}),
817
+ ingressMode,
818
+ }
694
819
  }
695
820
  const maxBodyBytes = options.maxBodyBytes ?? 1_000_000
696
821
  const runningIdentity = runtimeIdentity()
697
822
 
698
823
  const server = createServer((req, res) => {
699
- void bookingCopilotTrafficHandler({
700
- apiKey: options.apiKey,
824
+ const handled = bookingCopilotTrafficHandler({
825
+ ...(options.apiKey ? { apiKey: options.apiKey } : {}),
701
826
  composition,
702
827
  maxBodyBytes,
703
828
  ...(options.artifactId !== undefined ? { artifactId: options.artifactId } : {}),
704
829
  ingressMode,
705
830
  runningIdentity,
706
831
  })(req, res)
832
+ if (!options.tenancy) { void handled; return }
833
+ // One replica serves every tenant: whatever escapes one request ends that request, never the process.
834
+ handled.catch((error: unknown) => {
835
+ console.error('[booking-copilot] request failed:', JSON.stringify({ thrownType: error instanceof Error ? error.name : typeof error }))
836
+ if (!res.writableEnded) res.destroy()
837
+ })
707
838
  })
708
839
  let closePromise: Promise<void> | undefined
709
840