@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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- 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
|
-
|
|
28
|
-
|
|
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,
|
|
156
|
+
function releasePlannerSession(composition, sessionKey) {
|
|
141
157
|
const sessions = sessionsFor(composition);
|
|
142
|
-
const session = sessions.get(
|
|
158
|
+
const session = sessions.get(sessionKey);
|
|
143
159
|
if (!session)
|
|
144
160
|
return;
|
|
145
|
-
sessions.delete(
|
|
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 [
|
|
151
|
-
lifecycle.active.delete(
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
-
/**
|
|
387
|
-
|
|
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 (
|
|
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
|
|
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:
|
|
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 ?
|
|
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}:${
|
|
465
|
-
task =
|
|
525
|
+
replayKey = `receipt:${turn.receipt.actionId}:${scope.runtime.receiptDigest(turn.receipt)}`;
|
|
526
|
+
task = scope.runtime.continueWithReceipt(turn);
|
|
466
527
|
if (task.awaitingApproval) {
|
|
467
|
-
replayKey =
|
|
528
|
+
replayKey = scope.runtime.approvalPresentationRequestKey(turn.taskId);
|
|
468
529
|
}
|
|
469
|
-
replayEvents =
|
|
530
|
+
replayEvents = scope.runtime.readDecisionBatch(turn.taskId, replayKey);
|
|
470
531
|
if (!replayEvents && task.phase === 'terminal')
|
|
471
|
-
replayEvents =
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
511
|
-
task =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
616
|
+
const newlyPersistedProgress = scope.runtime.ensureProgressBatch(task.taskId, requestKey, fresh);
|
|
549
617
|
const progressEvents = newlyPersistedProgress.length > 0
|
|
550
618
|
? newlyPersistedProgress
|
|
551
|
-
: (
|
|
619
|
+
: (scope.runtime.readProgressBatch(task.taskId, requestKey) ?? []);
|
|
552
620
|
const approval = turn.kind === 'user.turn' ? turn.request.approval : undefined;
|
|
553
|
-
const flightKey =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
635
|
+
return scope.runtime.applyDecisionBatch(task.taskId, requestKey, [scope.runtime.approvalQuestion(task.taskId)], false);
|
|
566
636
|
}
|
|
567
637
|
const sessions = sessionsFor(composition);
|
|
568
|
-
const
|
|
569
|
-
sessions.
|
|
570
|
-
|
|
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 =
|
|
573
|
-
const updated =
|
|
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,
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
if (
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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);
|