@danceiny/gotry 0.2.0-rc.31 → 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 (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -11,6 +11,7 @@ import { BOOKING_COPILOT_ACCEPTED_TURN_KINDS, BOOKING_COPILOT_INGRESS_MODES, BOO
11
11
  import { bookingDigest, } from './runtime.js';
12
12
  import { validateBookingSurface } from './validation.js';
13
13
  import { extractBookingDispatchReason, normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.js';
14
+ import { tenantRejection } from './tenancy.js';
14
15
  /**
15
16
  * 单请求处理闭包(startBookingCopilotServer 的 createServer 回调原样摘出):
16
17
  * 探活(/healthz /status)+ 唯一 turn 路由 + 鉴权 + schema 头校验。
@@ -24,8 +25,23 @@ export function bookingCopilotTrafficHandler(deps) {
24
25
  sendJson(res, 404, { error: { code: 'not_found' } });
25
26
  return;
26
27
  }
27
- const auth = String(req.headers.authorization ?? '');
28
- if (!safeSecretEqual(auth, `Bearer ${apiKey}`)) {
28
+ // The one place a credential is read. The tenant form asks its authenticator, which names the caller; the shared
29
+ // form compares the deployment key as before. Neither reads a tenant from the request.
30
+ let caller = null;
31
+ if (composition.tenancy) {
32
+ try {
33
+ caller = await composition.tenancy.authenticate({ method: req.method ?? '', path: (req.url ?? '').split('?')[0] ?? '', headers: req.headers });
34
+ }
35
+ catch {
36
+ sendJson(res, 503, { error: { code: 'authentication_unavailable' } });
37
+ return;
38
+ }
39
+ if (!caller) {
40
+ sendJson(res, 401, { error: { code: 'unauthorized' } });
41
+ return;
42
+ }
43
+ }
44
+ else if (!apiKey || !safeSecretEqual(String(req.headers.authorization ?? ''), `Bearer ${apiKey}`)) {
29
45
  sendJson(res, 401, { error: { code: 'unauthorized' } });
30
46
  return;
31
47
  }
@@ -82,7 +98,7 @@ export function bookingCopilotTrafficHandler(deps) {
82
98
  const lifecycle = lifecycleFor(composition);
83
99
  lifecycle.acceptedRequests += 1;
84
100
  try {
85
- await handleBookingCopilotRequest(req, res, composition, maxBodyBytes, negotiatedBookingSurfaceFeatures(req));
101
+ await handleBookingCopilotRequest(req, res, composition, maxBodyBytes, negotiatedBookingSurfaceFeatures(req), caller);
86
102
  }
87
103
  finally {
88
104
  finishAcceptedRequest(lifecycle);
@@ -137,18 +153,18 @@ function schedulePlannerSessionClose(composition, session) {
137
153
  void cleanup.then(() => lifecycle.closing.delete(cleanup));
138
154
  }
139
155
  /** Detach a session from the request path and close it in the composition lifecycle. */
140
- function releasePlannerSession(composition, taskId) {
156
+ function releasePlannerSession(composition, sessionKey) {
141
157
  const sessions = sessionsFor(composition);
142
- const session = sessions.get(taskId);
158
+ const session = sessions.get(sessionKey);
143
159
  if (!session)
144
160
  return;
145
- sessions.delete(taskId);
161
+ sessions.delete(sessionKey);
146
162
  schedulePlannerSessionClose(composition, session);
147
163
  }
148
164
  async function drainPlannerSessions(composition) {
149
165
  const lifecycle = lifecycleFor(composition);
150
- for (const [taskId, session] of lifecycle.active) {
151
- lifecycle.active.delete(taskId);
166
+ for (const [sessionKey, session] of lifecycle.active) {
167
+ lifecycle.active.delete(sessionKey);
152
168
  schedulePlannerSessionClose(composition, session);
153
169
  }
154
170
  while (lifecycle.closing.size > 0) {
@@ -359,7 +375,7 @@ function fallbackErrorEvent(task, code) {
359
375
  error: { code: normalizeBookingErrorCode(code), message: safeBookingErrorMessage(normalizeBookingErrorCode(code)), retryable: false },
360
376
  };
361
377
  }
362
- function writeTypedError(res, composition, task, error, requestKey, includeSubmitted = false) {
378
+ function writeTypedError(res, runtime, task, error, requestKey, includeSubmitted = false, isTenantFault = () => false) {
363
379
  const code = normalizeBookingErrorCode(error);
364
380
  // Provider/adapter exceptions can contain response bodies, URLs or request
365
381
  // context. Preserve only the closed code and coarse thrown-value class.
@@ -370,21 +386,66 @@ function writeTypedError(res, composition, task, error, requestKey, includeSubmi
370
386
  try {
371
387
  const decision = { kind: 'error', error: { code, message: safeBookingErrorMessage(code), retryable: false } };
372
388
  if (requestKey) {
373
- const events = composition.runtime.applyDecisionBatch(task.taskId, requestKey, [decision], includeSubmitted, { suppressProgressStatuses: true });
389
+ const events = runtime.applyDecisionBatch(task.taskId, requestKey, [decision], includeSubmitted, { suppressProgressStatuses: true });
374
390
  for (const event of events)
375
391
  write(res, event);
376
392
  return;
377
393
  }
378
- write(res, composition.runtime.emitEvent(task.taskId, decision));
394
+ write(res, runtime.emitEvent(task.taskId, decision));
379
395
  }
380
- catch {
396
+ catch (writeError) {
397
+ // A lease lost under the durable write is not a planner answer: nothing can be acknowledged, so the caller cuts the connection.
398
+ if (isTenantFault(writeError))
399
+ throw writeError;
381
400
  // The stream is already committed. Emit a schema-valid, non-durable
382
401
  // typed envelope instead of silently returning an empty HTTP 200 stream.
383
402
  write(res, fallbackErrorEvent(task, code));
384
403
  }
385
404
  }
386
- /** Handles one already-authenticated request inside the sole HTTP listener. */
387
- async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes, negotiatedFeatures) {
405
+ /** The shared form's scope: the one runtime and planner of the composition, keys exactly as they were before tenants existed. */
406
+ function sharedScope(composition) {
407
+ const ingress = composition.ingressMode === 'bff-ingress-binding' && composition.ingressBinding && composition.principal
408
+ ? { binding: composition.ingressBinding, principal: composition.principal }
409
+ : null;
410
+ return {
411
+ key: null,
412
+ runtime: composition.runtime,
413
+ plannerFactory: composition.plannerFactory,
414
+ ingress,
415
+ sessionKey: (taskId) => taskId,
416
+ flightKey: (parts) => JSON.stringify(parts),
417
+ runDecision: (work) => work(),
418
+ isTenantFault: () => false,
419
+ release: () => undefined,
420
+ };
421
+ }
422
+ /** Handles one already-authenticated request inside the sole HTTP listener: places it in its scope, then serves it there. */
423
+ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes, negotiatedFeatures, caller) {
424
+ let scope;
425
+ if (composition.tenancy) {
426
+ // Derive the tenant, admit the turn against its quotas and open its guarded ledger, all before the body is read.
427
+ try {
428
+ scope = await composition.tenancy.beginTurn(caller);
429
+ }
430
+ catch (error) {
431
+ const rejection = tenantRejection(error);
432
+ if (rejection.retryAfterSeconds !== undefined)
433
+ res.setHeader('retry-after', String(rejection.retryAfterSeconds));
434
+ sendJson(res, rejection.status, rejection.body);
435
+ return;
436
+ }
437
+ }
438
+ else {
439
+ scope = sharedScope(composition);
440
+ }
441
+ try {
442
+ await serveBookingTurn(req, res, composition, scope, maxBodyBytes, negotiatedFeatures);
443
+ }
444
+ finally {
445
+ scope.release();
446
+ }
447
+ }
448
+ async function serveBookingTurn(req, res, composition, scope, maxBodyBytes, negotiatedFeatures) {
388
449
  let turn;
389
450
  let browserRequestKey;
390
451
  let requestBinding;
@@ -399,11 +460,11 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
399
460
  if (parsed.kind === 'user.turn.ingress') {
400
461
  const ingress = parsed;
401
462
  browserRequestKey = ingress.requestKey;
402
- if (composition.ingressMode !== 'bff-ingress-binding' || !composition.ingressBinding || !composition.principal) {
463
+ if (!scope.ingress) {
403
464
  sendJson(res, 503, { error: { code: 'trusted_ingress_binding_required', mode: composition.ingressMode ?? 'bff-bound-turn-only', acceptedTurnKinds: [...BOOKING_COPILOT_ACCEPTED_TURN_KINDS] } });
404
465
  return;
405
466
  }
406
- const binding = await composition.ingressBinding.bind(ingress, composition.principal);
467
+ const binding = await scope.ingress.binding.bind(ingress, scope.ingress.principal);
407
468
  if (!validIngressBinding(binding, ingress)) {
408
469
  sendJson(res, 502, { error: { code: 'invalid_ingress_binding' } });
409
470
  return;
@@ -423,7 +484,7 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
423
484
  ? { text: ingress.request.text, approval: { ...ingress.request.approval } }
424
485
  : { text: ingress.request.text };
425
486
  turn = { schemaVersion: BOOKING_SURFACE_SCHEMA_VERSION, kind: 'user.turn', taskId: binding.taskId, turnId: binding.turnId, workspace: proposedBoundWorkspace, request: boundRequest };
426
- requestBinding = { requestKey: ingress.requestKey, principal: composition.principal, ...(ingress.taskHandle ? { taskHandle: ingress.taskHandle } : {}) };
487
+ requestBinding = { requestKey: ingress.requestKey, principal: scope.ingress.principal, ...(ingress.taskHandle ? { taskHandle: ingress.taskHandle } : {}) };
427
488
  authorityFailureStatus = 502;
428
489
  const boundValid = validateBookingSurface(turn);
429
490
  if (!boundValid.ok) {
@@ -451,7 +512,7 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
451
512
  let replayEvents = null;
452
513
  let activeDecisionKey;
453
514
  try {
454
- const existingAtAdmission = turn.taskId ? composition.runtime.resumeTask(turn.taskId) : null;
515
+ const existingAtAdmission = turn.taskId ? scope.runtime.resumeTask(turn.taskId) : null;
455
516
  const narrowedWorkspace = narrowWorkspaceOptionalAuthority(turn.workspace, negotiatedFeatures, existingAtAdmission?.allowedActions);
456
517
  if (!narrowedWorkspace) {
457
518
  const code = authorityFailureStatus === 502 ? 'invalid_ingress_binding' : 'invalid_bound_turn_authority';
@@ -461,26 +522,26 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
461
522
  turn = { ...turn, workspace: narrowedWorkspace };
462
523
  fresh = turn.kind === 'action.receipt.continuation' ? false : existingAtAdmission === null;
463
524
  if (turn.kind === 'action.receipt.continuation') {
464
- replayKey = `receipt:${turn.receipt.actionId}:${composition.runtime.receiptDigest(turn.receipt)}`;
465
- task = composition.runtime.continueWithReceipt(turn);
525
+ replayKey = `receipt:${turn.receipt.actionId}:${scope.runtime.receiptDigest(turn.receipt)}`;
526
+ task = scope.runtime.continueWithReceipt(turn);
466
527
  if (task.awaitingApproval) {
467
- replayKey = composition.runtime.approvalPresentationRequestKey(turn.taskId);
528
+ replayKey = scope.runtime.approvalPresentationRequestKey(turn.taskId);
468
529
  }
469
- replayEvents = composition.runtime.readDecisionBatch(turn.taskId, replayKey);
530
+ replayEvents = scope.runtime.readDecisionBatch(turn.taskId, replayKey);
470
531
  if (!replayEvents && task.phase === 'terminal')
471
- replayEvents = composition.runtime.terminalDecisionBatch(turn.taskId, replayKey);
532
+ replayEvents = scope.runtime.terminalDecisionBatch(turn.taskId, replayKey);
472
533
  }
473
534
  else {
474
535
  const existing = existingAtAdmission;
475
536
  if (requestBinding && turn.kind === 'user.turn')
476
- composition.runtime.assertRequestBinding(browserRequestKey, turn, requestBinding);
537
+ scope.runtime.assertRequestBinding(browserRequestKey, turn, requestBinding);
477
538
  const requestReplayKey = existing && browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : undefined;
478
539
  const directReplayKey = existing && turn.kind === 'user.turn' && turn.turnId ? `turn:${existing.taskId}:${turn.turnId}` : undefined;
479
540
  const stableReplayKey = requestReplayKey ?? directReplayKey;
480
- const stableReplay = stableReplayKey ? composition.runtime.readDecisionBatch(existing.taskId, stableReplayKey) : null;
541
+ const stableReplay = stableReplayKey ? scope.runtime.readDecisionBatch(existing.taskId, stableReplayKey) : null;
481
542
  if (stableReplay) {
482
543
  if (existing && turn.kind === 'user.turn' && directReplayKey)
483
- composition.runtime.assertTurnBinding(existing.taskId, turn);
544
+ scope.runtime.assertTurnBinding(existing.taskId, turn);
484
545
  task = existing;
485
546
  replayKey = stableReplayKey;
486
547
  replayEvents = stableReplay;
@@ -490,7 +551,7 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
490
551
  }
491
552
  else if (existing?.phase === 'waiting_receipt') {
492
553
  if (turn.kind === 'user.turn' && turn.request.approval)
493
- composition.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval);
554
+ scope.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval);
494
555
  const workspace = turn.workspace;
495
556
  if (workspace.contextRef !== existing.contextRef || workspace.surface !== existing.surface || workspace.revision !== existing.revision || workspace.capabilities.surface !== existing.surface || !sameCapabilities(workspace.capabilities.allowedActions, existing.allowedActions))
496
557
  throw new Error('task_conflict:workspace_mismatch');
@@ -500,23 +561,30 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
500
561
  if (!turn.turnId || existing.lastTurnId !== turn.turnId)
501
562
  throw new Error('receipt_required');
502
563
  replayKey = browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : `turn:${existing.taskId}:${existing.lastTurnId ?? existing.userTurnCount}`;
503
- replayEvents = composition.runtime.readDecisionBatch(existing.taskId, replayKey);
564
+ replayEvents = scope.runtime.readDecisionBatch(existing.taskId, replayKey);
504
565
  if (!replayEvents)
505
566
  throw new Error('receipt_required');
506
567
  task = existing;
507
568
  }
508
569
  else {
509
570
  if (existing && turn.kind === 'user.turn' && turn.request.approval)
510
- composition.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval);
511
- task = composition.runtime.startTask(turn, requestBinding);
571
+ scope.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval);
572
+ task = scope.runtime.startTask(turn, requestBinding);
512
573
  if (turn.kind === 'user.turn' && turn.taskId) {
513
574
  replayKey = browserRequestKey ? `ingress:${task.taskId}:${browserRequestKey}` : `turn:${task.taskId}:${turn.turnId ?? task.userTurnCount}`;
514
- replayEvents = composition.runtime.readDecisionBatch(task.taskId, replayKey);
575
+ replayEvents = scope.runtime.readDecisionBatch(task.taskId, replayKey);
515
576
  }
516
577
  }
517
578
  }
518
579
  }
519
580
  catch (error) {
581
+ if (scope.isTenantFault(error)) {
582
+ const rejection = tenantRejection(error);
583
+ if (rejection.retryAfterSeconds !== undefined)
584
+ res.setHeader('retry-after', String(rejection.retryAfterSeconds));
585
+ sendJson(res, rejection.status, rejection.body);
586
+ return;
587
+ }
520
588
  const code = normalizeBookingErrorCode(error);
521
589
  console.error(JSON.stringify({ code, reason: extractBookingDispatchReason(error) }));
522
590
  sendJson(res, 409, { error: { code } });
@@ -526,7 +594,7 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
526
594
  res.flushHeaders();
527
595
  try {
528
596
  if (replayEvents) {
529
- const progressEvents = composition.runtime.readProgressBatch(task.taskId, replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`) ?? [];
597
+ const progressEvents = scope.runtime.readProgressBatch(task.taskId, replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`) ?? [];
530
598
  for (const event of progressEvents)
531
599
  write(res, event);
532
600
  if (progressEvents.length) {
@@ -534,7 +602,7 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
534
602
  await new Promise((resolve) => setImmediate(resolve));
535
603
  }
536
604
  if (task.phase === 'terminal' || task.phase === 'error')
537
- releasePlannerSession(composition, task.taskId);
605
+ releasePlannerSession(composition, scope.sessionKey(task.taskId));
538
606
  for (const event of replayEvents)
539
607
  write(res, event);
540
608
  }
@@ -545,36 +613,39 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
545
613
  // committed progress but before its planner flight finishes. Re-serve
546
614
  // the durable progress batch so it receives the same prefix instead of
547
615
  // exposing a terminal-only tail.
548
- const newlyPersistedProgress = composition.runtime.ensureProgressBatch(task.taskId, requestKey, fresh);
616
+ const newlyPersistedProgress = scope.runtime.ensureProgressBatch(task.taskId, requestKey, fresh);
549
617
  const progressEvents = newlyPersistedProgress.length > 0
550
618
  ? newlyPersistedProgress
551
- : (composition.runtime.readProgressBatch(task.taskId, requestKey) ?? []);
619
+ : (scope.runtime.readProgressBatch(task.taskId, requestKey) ?? []);
552
620
  const approval = turn.kind === 'user.turn' ? turn.request.approval : undefined;
553
- const flightKey = JSON.stringify([task.taskId, approval ? `approval:${bookingDigest(approval)}` : requestKey]);
621
+ const flightKey = scope.flightKey([task.taskId, approval ? `approval:${bookingDigest(approval)}` : requestKey]);
554
622
  // Register or join the decision flight before yielding after the progress
555
623
  // flush. A duplicate that observed the durable progress must never miss
556
624
  // an about-to-finish flight and pay for a second model invocation.
557
- const decisionOutcome = runDecisionSingleFlight(composition, flightKey, async () => {
625
+ // The decision work runs inside the scope: in the tenant form that proves the lease before and after it, lets a
626
+ // handoff drain it, and keeps its events from reaching the client if the lease was lost meanwhile.
627
+ const decisionOutcome = runDecisionSingleFlight(composition, flightKey, () => scope.runDecision(async () => {
558
628
  // Cover the complementary late-join case: the prior flight may have
559
629
  // committed its exact batch and left the in-memory map between request
560
630
  // admission and this work starting.
561
- const durableDecision = composition.runtime.readDecisionBatch(task.taskId, requestKey);
631
+ const durableDecision = scope.runtime.readDecisionBatch(task.taskId, requestKey);
562
632
  if (durableDecision)
563
633
  return durableDecision;
564
634
  if (turn.kind === 'action.receipt.continuation' && task.awaitingApproval) {
565
- return composition.runtime.applyDecisionBatch(task.taskId, requestKey, [composition.runtime.approvalQuestion(task.taskId)], false);
635
+ return scope.runtime.applyDecisionBatch(task.taskId, requestKey, [scope.runtime.approvalQuestion(task.taskId)], false);
566
636
  }
567
637
  const sessions = sessionsFor(composition);
568
- const session = sessions.get(task.taskId) ?? composition.plannerFactory(task);
569
- sessions.set(task.taskId, session);
570
- const decisions = await session.next({ turn, task: composition.runtime.resumeTask(task.taskId) ?? task });
638
+ const sessionKey = scope.sessionKey(task.taskId);
639
+ const session = sessions.get(sessionKey) ?? scope.plannerFactory(task);
640
+ sessions.set(sessionKey, session);
641
+ const decisions = await session.next({ turn, task: scope.runtime.resumeTask(task.taskId) ?? task });
571
642
  const plannerDecisions = decisions.length ? decisions : [{ kind: 'error', error: { code: 'PLANNER_NO_DECISION', message: 'Planner returned no typed decision.', retryable: false } }];
572
- const events = composition.runtime.applyDecisionBatch(task.taskId, requestKey, plannerDecisions, fresh, { suppressProgressStatuses: true });
573
- const updated = composition.runtime.resumeTask(task.taskId);
643
+ const events = scope.runtime.applyDecisionBatch(task.taskId, requestKey, plannerDecisions, fresh, { suppressProgressStatuses: true });
644
+ const updated = scope.runtime.resumeTask(task.taskId);
574
645
  if (updated?.phase === 'terminal' || updated?.phase === 'error')
575
- releasePlannerSession(composition, task.taskId);
646
+ releasePlannerSession(composition, sessionKey);
576
647
  return events;
577
- }).then((events) => ({ ok: true, events }), (error) => ({ ok: false, error }));
648
+ })).then((events) => ({ ok: true, events }), (error) => ({ ok: false, error }));
578
649
  for (const event of progressEvents)
579
650
  write(res, event);
580
651
  if (progressEvents.length) {
@@ -589,61 +660,103 @@ async function handleBookingCopilotRequest(req, res, composition, maxBodyBytes,
589
660
  }
590
661
  }
591
662
  catch (error) {
592
- writeTypedError(res, composition, task, error, activeDecisionKey, fresh);
593
- const updated = composition.runtime.resumeTask(task.taskId);
594
- if (updated?.phase === 'terminal' || updated?.phase === 'error')
595
- releasePlannerSession(composition, task.taskId);
663
+ // A lease lost, fenced or handed off while the stream is open cannot be told to the client as an answer: nothing
664
+ // was acknowledged, so the connection is cut and the BFF retries the same request key on the next owner.
665
+ if (scope.isTenantFault(error)) {
666
+ res.destroy();
667
+ return;
668
+ }
669
+ try {
670
+ writeTypedError(res, scope.runtime, task, error, activeDecisionKey, fresh, scope.isTenantFault);
671
+ const updated = scope.runtime.resumeTask(task.taskId);
672
+ if (updated?.phase === 'terminal' || updated?.phase === 'error')
673
+ releasePlannerSession(composition, scope.sessionKey(task.taskId));
674
+ }
675
+ catch (faultWhileAnswering) {
676
+ // The lease went while the planner's error was being recorded: the same rule as above, and never an escaped rejection.
677
+ if (!scope.isTenantFault(faultWhileAnswering))
678
+ throw faultWhileAnswering;
679
+ res.destroy();
680
+ }
596
681
  }
597
682
  finally {
598
683
  res.end();
599
684
  }
600
685
  }
601
686
  export function startBookingCopilotServer(options) {
602
- if (!options.apiKey)
687
+ // The shared form's key is checked first, exactly as before the tenant form existed.
688
+ if (!options.tenancy && !options.apiKey)
603
689
  return Promise.reject(new Error('booking_copilot_api_key_required'));
604
690
  if (options.artifactId !== undefined && !/^[0-9a-f]{40}$/.test(options.artifactId)) {
605
691
  return Promise.reject(new Error('booking_copilot_artifact_id_invalid'));
606
692
  }
607
- if (!options.runtime || !options.plannerFactory) {
608
- return Promise.reject(new Error('booking_copilot_runtime_and_planner_required'));
609
- }
610
- const hasIngressBinding = Boolean(options.ingressBinding);
611
- const hasPrincipal = Boolean(options.principal);
612
- if (hasIngressBinding !== hasPrincipal) {
613
- return Promise.reject(new Error('booking_copilot_ingress_binding_pair_required'));
614
- }
615
- if (hasIngressBinding && (typeof options.ingressBinding?.bind !== 'function'
616
- || typeof options.principal?.subject !== 'string' || options.principal.subject.length === 0 || options.principal.subject.length > 256
617
- || typeof options.principal?.scope !== 'string' || options.principal.scope.length === 0 || options.principal.scope.length > 256)) {
618
- return Promise.reject(new Error('booking_copilot_ingress_binding_invalid'));
619
- }
620
- const ingressMode = options.ingressMode ?? (hasIngressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only');
621
- if (!BOOKING_COPILOT_INGRESS_MODES.includes(ingressMode)) {
622
- return Promise.reject(new Error('booking_copilot_ingress_mode_invalid'));
623
- }
624
- if (ingressMode === 'bff-ingress-binding' && !hasIngressBinding) {
625
- return Promise.reject(new Error('booking_copilot_ingress_binding_required'));
693
+ let composition;
694
+ let ingressMode;
695
+ if (options.tenancy) {
696
+ // One authentication system per form: the tenant form has no shared key and no shared runtime, planner or principal.
697
+ if (options.apiKey)
698
+ return Promise.reject(new Error('booking_copilot_tenancy_api_key_conflict'));
699
+ if (options.runtime || options.plannerFactory || options.ingressBinding || options.principal) {
700
+ return Promise.reject(new Error('booking_copilot_tenancy_shared_conflict'));
701
+ }
702
+ ingressMode = options.tenancy.ingressMode;
703
+ if (options.ingressMode !== undefined && options.ingressMode !== ingressMode) {
704
+ return Promise.reject(new Error('booking_copilot_ingress_mode_conflict'));
705
+ }
706
+ composition = { tenancy: options.tenancy, ingressMode };
626
707
  }
627
- if (ingressMode === 'bff-bound-turn-only' && hasIngressBinding) {
628
- return Promise.reject(new Error('booking_copilot_ingress_mode_conflict'));
708
+ else {
709
+ if (!options.runtime || !options.plannerFactory) {
710
+ return Promise.reject(new Error('booking_copilot_runtime_and_planner_required'));
711
+ }
712
+ const hasIngressBinding = Boolean(options.ingressBinding);
713
+ const hasPrincipal = Boolean(options.principal);
714
+ if (hasIngressBinding !== hasPrincipal) {
715
+ return Promise.reject(new Error('booking_copilot_ingress_binding_pair_required'));
716
+ }
717
+ if (hasIngressBinding && (typeof options.ingressBinding?.bind !== 'function'
718
+ || typeof options.principal?.subject !== 'string' || options.principal.subject.length === 0 || options.principal.subject.length > 256
719
+ || typeof options.principal?.scope !== 'string' || options.principal.scope.length === 0 || options.principal.scope.length > 256)) {
720
+ return Promise.reject(new Error('booking_copilot_ingress_binding_invalid'));
721
+ }
722
+ ingressMode = options.ingressMode ?? (hasIngressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only');
723
+ if (!BOOKING_COPILOT_INGRESS_MODES.includes(ingressMode)) {
724
+ return Promise.reject(new Error('booking_copilot_ingress_mode_invalid'));
725
+ }
726
+ if (ingressMode === 'bff-ingress-binding' && !hasIngressBinding) {
727
+ return Promise.reject(new Error('booking_copilot_ingress_binding_required'));
728
+ }
729
+ if (ingressMode === 'bff-bound-turn-only' && hasIngressBinding) {
730
+ return Promise.reject(new Error('booking_copilot_ingress_mode_conflict'));
731
+ }
732
+ composition = {
733
+ runtime: options.runtime,
734
+ plannerFactory: options.plannerFactory,
735
+ ...(hasIngressBinding ? { ingressBinding: options.ingressBinding, principal: options.principal } : {}),
736
+ ingressMode,
737
+ };
629
738
  }
630
- const composition = {
631
- runtime: options.runtime,
632
- plannerFactory: options.plannerFactory,
633
- ...(hasIngressBinding ? { ingressBinding: options.ingressBinding, principal: options.principal } : {}),
634
- ingressMode,
635
- };
636
739
  const maxBodyBytes = options.maxBodyBytes ?? 1_000_000;
637
740
  const runningIdentity = runtimeIdentity();
638
741
  const server = createServer((req, res) => {
639
- void bookingCopilotTrafficHandler({
640
- apiKey: options.apiKey,
742
+ const handled = bookingCopilotTrafficHandler({
743
+ ...(options.apiKey ? { apiKey: options.apiKey } : {}),
641
744
  composition,
642
745
  maxBodyBytes,
643
746
  ...(options.artifactId !== undefined ? { artifactId: options.artifactId } : {}),
644
747
  ingressMode,
645
748
  runningIdentity,
646
749
  })(req, res);
750
+ if (!options.tenancy) {
751
+ void handled;
752
+ return;
753
+ }
754
+ // One replica serves every tenant: whatever escapes one request ends that request, never the process.
755
+ handled.catch((error) => {
756
+ console.error('[booking-copilot] request failed:', JSON.stringify({ thrownType: error instanceof Error ? error.name : typeof error }));
757
+ if (!res.writableEnded)
758
+ res.destroy();
759
+ });
647
760
  });
648
761
  let closePromise;
649
762
  return new Promise((resolve, reject) => {
@@ -1,4 +1,5 @@
1
1
  /** Production composition entry for GoTry's BFF-only Booking Copilot server. */
2
+ import { AsyncResource } from 'node:async_hooks';
2
3
  import { mkdirSync } from 'node:fs';
3
4
  import { isAbsolute, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -6,6 +7,7 @@ import { ensureLedger } from '../state-ledger.js';
6
7
  import { buildDshPlannerEnvironment, createDshEmbeddedBookingPlanner, } from './dsh-planner.js';
7
8
  import { BookingCopilotTaskRuntime } from './runtime.js';
8
9
  import { BOOKING_COPILOT_INGRESS_MODES } from './contracts.js';
10
+ import { assertManagedDshLimitsEnforceable } from './managed-dsh-run-port.js';
9
11
  import { startBookingCopilotServer, } from './server.js';
10
12
  const DEFAULT_DEPENDENCIES = {
11
13
  ensureLedger,
@@ -59,6 +61,155 @@ export function resolveBookingCopilotStartupConfig(env = process.env) {
59
61
  ingressMode: ingressMode,
60
62
  };
61
63
  }
64
+ /** The listener settings of the multi-tenant form. There is no deployment key and no shared state root: the tenancy holds both roles. */
65
+ export function resolveBookingCopilotTenantStartupConfig(env = process.env) {
66
+ const artifactId = env.GOTRY_BOOKING_COPILOT_ARTIFACT_ID || undefined;
67
+ if (artifactId !== undefined && !/^[0-9a-f]{40}$/.test(artifactId)) {
68
+ throw new Error('booking_copilot_artifact_id_invalid');
69
+ }
70
+ const rawIngressMode = env.GOTRY_BOOKING_COPILOT_INGRESS_MODE;
71
+ if (rawIngressMode !== undefined && rawIngressMode !== '' && !BOOKING_COPILOT_INGRESS_MODES.includes(rawIngressMode)) {
72
+ throw new Error('booking_copilot_ingress_mode_invalid');
73
+ }
74
+ return {
75
+ host: env.GOTRY_BOOKING_COPILOT_HOST || '127.0.0.1',
76
+ port: parsePort(env.GOTRY_BOOKING_COPILOT_PORT),
77
+ ...(artifactId === undefined ? {} : { artifactId }),
78
+ ...(rawIngressMode === undefined || rawIngressMode === '' ? {} : { ingressMode: rawIngressMode }),
79
+ };
80
+ }
81
+ /**
82
+ * Ceilings for each planner process tree in the multi-tenant form (docs/rfc/aaas-rfc.md §4.8, threat S-17). Every
83
+ * tenant on a replica shares its CPU and memory, so one runaway child must be stopped by a limit and not by the
84
+ * operating system's OOM killer. These numbers are a ceiling for a runaway, not a budget, and are unreviewed: they sit
85
+ * well above what an idle booted planner tree costs (see docs/ops/aaas-capacity-baseline.md) and a deployment that
86
+ * knows better passes its own `resourceLimits`.
87
+ */
88
+ export const DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS = Object.freeze({
89
+ heapMb: 512,
90
+ maxRssBytes: 1536 * 1024 * 1024,
91
+ maxCpuSeconds: 120,
92
+ maxLineChars: 8 * 1024 * 1024,
93
+ });
94
+ /**
95
+ * The sampler is the only thing between a runaway planner child and the replica. If it cannot see the process table, the
96
+ * limits would silently govern nothing, so this fails first, with a name, before any child starts. Limits that need no
97
+ * sampler (a heap ceiling, a line cap) have nothing to check.
98
+ */
99
+ export const assertPlannerLimitsEnforceable = assertManagedDshLimitsEnforceable;
100
+ /**
101
+ * One planner per tenant, each rooted in that tenant's own directory, so planner sessions and working files never sit
102
+ * beside another tenant's. Planners are created lazily by the dsh adapter (no process starts until a session needs one).
103
+ * The dsh adapter does not report token usage yet, so until it does every invocation is charged the quota policy's
104
+ * worst case (`TenantQuotas.chargeUnprovenInvocation`).
105
+ */
106
+ export function createDshTenantPlannerProvider(base, createPlanner = createDshEmbeddedBookingPlanner) {
107
+ // The real dsh planner of the tenant form never holds the deployment's LLM credential (docs/rfc/aaas-rfc.md §4.8, S-18):
108
+ // it is built with a broker that gives each planner process tree a ticket. A planner factory injected by a suite is
109
+ // the caller's own business and is not asked for one.
110
+ if (createPlanner === createDshEmbeddedBookingPlanner && base.llmTickets === undefined) {
111
+ throw new Error('tenant_planner_credential_broker_required');
112
+ }
113
+ const handles = [];
114
+ const realPlanner = createPlanner === createDshEmbeddedBookingPlanner;
115
+ let governable = false;
116
+ return {
117
+ async forTenant(context) {
118
+ if (realPlanner && !governable) {
119
+ await assertPlannerLimitsEnforceable(base.resourceLimits ?? DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS);
120
+ governable = true;
121
+ }
122
+ // Tickets issued for this tenant report their measured usage to this tenant's meter. The report is bound to the
123
+ // async context where the ticket is issued, which is inside the planner invocation that needs the port: the broker
124
+ // answers from its own request context, and the report must still land on the invocation that asked (and on no other).
125
+ const issuer = base.llmTickets;
126
+ const llmTickets = issuer && {
127
+ issue: (options = {}) => issuer.issue({
128
+ ...options,
129
+ onUsage: AsyncResource.bind((report) => context.reportUsage(report.model, report.usage)),
130
+ }),
131
+ };
132
+ const handle = await createPlanner({
133
+ ...base,
134
+ ...(llmTickets ? { llmTickets } : {}),
135
+ resourceLimits: base.resourceLimits ?? DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS,
136
+ // Both come from the tenant, never from the deployment-wide base: a shared label or root would defeat the point.
137
+ cachePartition: context.cachePartition,
138
+ stateRoot: context.tenantRoot,
139
+ });
140
+ handles.push(handle);
141
+ return handle.plannerFactory;
142
+ },
143
+ async close() {
144
+ const failures = [];
145
+ for (const handle of handles.splice(0)) {
146
+ try {
147
+ await handle.close();
148
+ }
149
+ catch (error) {
150
+ failures.push(error);
151
+ }
152
+ }
153
+ if (failures.length === 1)
154
+ throw failures[0];
155
+ if (failures.length > 1)
156
+ throw new AggregateError(failures, 'booking_copilot_planner_shutdown_failed');
157
+ },
158
+ };
159
+ }
160
+ async function startTenantCopilot(env, dependencies, tenancy) {
161
+ if (dependencies.ingressBinding || dependencies.principal)
162
+ throw new Error('booking_copilot_tenancy_shared_conflict');
163
+ const config = resolveBookingCopilotTenantStartupConfig(env);
164
+ if (config.ingressMode !== undefined && config.ingressMode !== tenancy.ingressMode)
165
+ throw new Error('booking_copilot_ingress_mode_conflict');
166
+ let server;
167
+ try {
168
+ server = await dependencies.startServer({
169
+ tenancy,
170
+ ingressMode: tenancy.ingressMode,
171
+ host: config.host,
172
+ port: config.port,
173
+ ...(config.artifactId === undefined ? {} : { artifactId: config.artifactId }),
174
+ });
175
+ }
176
+ catch (error) {
177
+ try {
178
+ await tenancy.close();
179
+ }
180
+ catch (cleanupError) {
181
+ throw new AggregateError([error, cleanupError], 'booking_copilot_startup_and_cleanup_failed');
182
+ }
183
+ throw error;
184
+ }
185
+ let closed = false;
186
+ return {
187
+ port: server.port,
188
+ async close() {
189
+ if (closed)
190
+ return;
191
+ closed = true;
192
+ const failures = [];
193
+ // Stop taking turns and drain the ones in flight first; then give the tenants back (planners, then leases).
194
+ try {
195
+ await server.close();
196
+ }
197
+ catch (error) {
198
+ failures.push(error);
199
+ }
200
+ try {
201
+ await tenancy.close();
202
+ }
203
+ catch (error) {
204
+ failures.push(error);
205
+ }
206
+ if (failures.length === 1)
207
+ throw failures[0];
208
+ if (failures.length > 1)
209
+ throw new AggregateError(failures, 'booking_copilot_shutdown_failed');
210
+ },
211
+ };
212
+ }
62
213
  async function closeAll(server, planner, ledger) {
63
214
  const failures = [];
64
215
  for (const close of [
@@ -85,6 +236,8 @@ async function closeAll(server, planner, ledger) {
85
236
  * HTTP/SSE server. Only the HTTP layer receives the BFF deployment key.
86
237
  */
87
238
  export async function startBookingCopilotFromEnvironment(env = process.env, dependencies = DEFAULT_DEPENDENCIES) {
239
+ if (dependencies.tenancy)
240
+ return startTenantCopilot(env, dependencies, dependencies.tenancy);
88
241
  const config = resolveBookingCopilotStartupConfig(env);
89
242
  const activeDependencies = dependencies;
90
243
  const hasIngressBinding = Boolean(activeDependencies.ingressBinding);