@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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +148 -47
- package/dist/capabilities/session/gui-flight.js +949 -0
- package/dist/capabilities/session/gui-hotel.js +470 -0
- package/dist/capabilities/session-search.js +102 -14
- 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/bootstrap-tests.js +23 -6
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +211 -6
- 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/gotry-web-api.js +64 -2
- package/dist/src/index.js +70 -18
- 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 +5 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +185 -67
- package/ts/capabilities/session/gui-flight.ts +1022 -0
- package/ts/capabilities/session/gui-hotel.ts +505 -0
- package/ts/capabilities/session-search.ts +130 -18
- 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/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +67 -19
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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,
|
|
274
|
+
function releasePlannerSession(composition: BookingCopilotComposition, sessionKey: string): void {
|
|
234
275
|
const sessions = sessionsFor(composition)
|
|
235
|
-
const session = sessions.get(
|
|
276
|
+
const session = sessions.get(sessionKey)
|
|
236
277
|
if (!session) return
|
|
237
|
-
sessions.delete(
|
|
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 [
|
|
244
|
-
lifecycle.active.delete(
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
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:
|
|
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 ?
|
|
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}:${
|
|
548
|
-
task =
|
|
638
|
+
replayKey = `receipt:${turn.receipt.actionId}:${scope.runtime.receiptDigest(turn.receipt)}`
|
|
639
|
+
task = scope.runtime.continueWithReceipt(turn)
|
|
549
640
|
if (task.awaitingApproval) {
|
|
550
|
-
replayKey =
|
|
641
|
+
replayKey = scope.runtime.approvalPresentationRequestKey(turn.taskId)
|
|
551
642
|
}
|
|
552
|
-
replayEvents =
|
|
553
|
-
if (!replayEvents && task.phase === 'terminal') replayEvents =
|
|
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')
|
|
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 ?
|
|
651
|
+
const stableReplay = stableReplayKey ? scope.runtime.readDecisionBatch(existing!.taskId, stableReplayKey) : null
|
|
561
652
|
if (stableReplay) {
|
|
562
|
-
if (existing && turn.kind === 'user.turn' && directReplayKey)
|
|
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)
|
|
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 =
|
|
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)
|
|
582
|
-
task =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
711
|
+
const newlyPersistedProgress = scope.runtime.ensureProgressBatch(task.taskId, requestKey, fresh)
|
|
615
712
|
const progressEvents = newlyPersistedProgress.length > 0
|
|
616
713
|
? newlyPersistedProgress
|
|
617
|
-
: (
|
|
714
|
+
: (scope.runtime.readProgressBatch(task.taskId, requestKey) ?? [])
|
|
618
715
|
const approval = turn.kind === 'user.turn' ? turn.request.approval : undefined
|
|
619
|
-
const flightKey =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
729
|
+
return scope.runtime.applyDecisionBatch(task.taskId, requestKey, [scope.runtime.approvalQuestion(task.taskId)], false)
|
|
631
730
|
}
|
|
632
731
|
const sessions = sessionsFor(composition)
|
|
633
|
-
const
|
|
634
|
-
sessions.
|
|
635
|
-
|
|
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 =
|
|
638
|
-
const updated =
|
|
639
|
-
if (updated?.phase === 'terminal' || updated?.phase === 'error') releasePlannerSession(composition,
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
|