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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -1,5 +1,6 @@
1
1
  /** Production composition entry for GoTry's BFF-only Booking Copilot server. */
2
2
 
3
+ import { AsyncResource } from 'node:async_hooks'
3
4
  import { mkdirSync } from 'node:fs'
4
5
  import { isAbsolute, resolve } from 'node:path'
5
6
  import { fileURLToPath } from 'node:url'
@@ -12,6 +13,9 @@ import {
12
13
  } from './dsh-planner.ts'
13
14
  import { BookingCopilotTaskRuntime } from './runtime.ts'
14
15
  import { BOOKING_COPILOT_INGRESS_MODES, type BookingCopilotIngressMode, type BookingIngressBinding, type BookingIngressPrincipal } from './contracts.ts'
16
+ import type { BookingTenancy, TenantPlannerProvider } from './tenancy.ts'
17
+ import { assertManagedDshLimitsEnforceable, type ManagedDshResourceLimits } from './managed-dsh-run-port.ts'
18
+ import type { LlmTicketIssuer, LlmTicketOptions, LlmUsageReport } from '../tenant-seam/llm-broker.ts'
15
19
  import {
16
20
  startBookingCopilotServer,
17
21
  type BookingCopilotServerHandle,
@@ -42,6 +46,11 @@ export interface BookingCopilotStartupDependencies {
42
46
  /** Optional in-process BFF identity seam; standalone use is bound-turn-only. */
43
47
  ingressBinding?: BookingIngressBinding
44
48
  principal?: BookingIngressPrincipal
49
+ /**
50
+ * The multi-tenant form (AaaS). When present, no shared ledger, runtime, planner or principal is created: every
51
+ * request is authenticated and placed in its own tenant by the tenancy, which this process then owns and closes.
52
+ */
53
+ tenancy?: BookingTenancy
45
54
  startServer(options: BookingCopilotServerOptions): Promise<BookingCopilotServerHandle>
46
55
  }
47
56
 
@@ -97,6 +106,151 @@ export function resolveBookingCopilotStartupConfig(
97
106
  }
98
107
  }
99
108
 
109
+ export interface BookingCopilotTenantStartupConfig {
110
+ host: string
111
+ port: number
112
+ artifactId?: string
113
+ /** Set only when the environment names a mode explicitly; it must then agree with the tenancy. */
114
+ ingressMode?: BookingCopilotIngressMode
115
+ }
116
+
117
+ /** The listener settings of the multi-tenant form. There is no deployment key and no shared state root: the tenancy holds both roles. */
118
+ export function resolveBookingCopilotTenantStartupConfig(
119
+ env: Record<string, string | undefined> = process.env,
120
+ ): BookingCopilotTenantStartupConfig {
121
+ const artifactId = env.GOTRY_BOOKING_COPILOT_ARTIFACT_ID || undefined
122
+ if (artifactId !== undefined && !/^[0-9a-f]{40}$/.test(artifactId)) {
123
+ throw new Error('booking_copilot_artifact_id_invalid')
124
+ }
125
+ const rawIngressMode = env.GOTRY_BOOKING_COPILOT_INGRESS_MODE
126
+ if (rawIngressMode !== undefined && rawIngressMode !== '' && !(BOOKING_COPILOT_INGRESS_MODES as readonly string[]).includes(rawIngressMode)) {
127
+ throw new Error('booking_copilot_ingress_mode_invalid')
128
+ }
129
+ return {
130
+ host: env.GOTRY_BOOKING_COPILOT_HOST || '127.0.0.1',
131
+ port: parsePort(env.GOTRY_BOOKING_COPILOT_PORT),
132
+ ...(artifactId === undefined ? {} : { artifactId }),
133
+ ...(rawIngressMode === undefined || rawIngressMode === '' ? {} : { ingressMode: rawIngressMode as BookingCopilotIngressMode }),
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Ceilings for each planner process tree in the multi-tenant form (docs/rfc/aaas-rfc.md §4.8, threat S-17). Every
139
+ * tenant on a replica shares its CPU and memory, so one runaway child must be stopped by a limit and not by the
140
+ * operating system's OOM killer. These numbers are a ceiling for a runaway, not a budget, and are unreviewed: they sit
141
+ * well above what an idle booted planner tree costs (see docs/ops/aaas-capacity-baseline.md) and a deployment that
142
+ * knows better passes its own `resourceLimits`.
143
+ */
144
+ export const DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS: ManagedDshResourceLimits = Object.freeze({
145
+ heapMb: 512,
146
+ maxRssBytes: 1536 * 1024 * 1024,
147
+ maxCpuSeconds: 120,
148
+ maxLineChars: 8 * 1024 * 1024,
149
+ })
150
+
151
+ /**
152
+ * The sampler is the only thing between a runaway planner child and the replica. If it cannot see the process table, the
153
+ * limits would silently govern nothing, so this fails first, with a name, before any child starts. Limits that need no
154
+ * sampler (a heap ceiling, a line cap) have nothing to check.
155
+ */
156
+ export const assertPlannerLimitsEnforceable: (limits: ManagedDshResourceLimits) => Promise<void> = assertManagedDshLimitsEnforceable
157
+
158
+ /**
159
+ * One planner per tenant, each rooted in that tenant's own directory, so planner sessions and working files never sit
160
+ * beside another tenant's. Planners are created lazily by the dsh adapter (no process starts until a session needs one).
161
+ * The dsh adapter does not report token usage yet, so until it does every invocation is charged the quota policy's
162
+ * worst case (`TenantQuotas.chargeUnprovenInvocation`).
163
+ */
164
+ export function createDshTenantPlannerProvider(
165
+ base: Omit<DshEmbeddedBookingPlannerOptions, 'stateRoot'>,
166
+ createPlanner: (options: DshEmbeddedBookingPlannerOptions) => Promise<DshEmbeddedBookingPlannerHandle> = createDshEmbeddedBookingPlanner,
167
+ ): TenantPlannerProvider {
168
+ // The real dsh planner of the tenant form never holds the deployment's LLM credential (docs/rfc/aaas-rfc.md §4.8, S-18):
169
+ // it is built with a broker that gives each planner process tree a ticket. A planner factory injected by a suite is
170
+ // the caller's own business and is not asked for one.
171
+ if (createPlanner === createDshEmbeddedBookingPlanner && base.llmTickets === undefined) {
172
+ throw new Error('tenant_planner_credential_broker_required')
173
+ }
174
+ const handles: DshEmbeddedBookingPlannerHandle[] = []
175
+ const realPlanner = createPlanner === createDshEmbeddedBookingPlanner
176
+ let governable = false
177
+ return {
178
+ async forTenant(context) {
179
+ if (realPlanner && !governable) {
180
+ await assertPlannerLimitsEnforceable(base.resourceLimits ?? DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS)
181
+ governable = true
182
+ }
183
+ // Tickets issued for this tenant report their measured usage to this tenant's meter. The report is bound to the
184
+ // async context where the ticket is issued, which is inside the planner invocation that needs the port: the broker
185
+ // answers from its own request context, and the report must still land on the invocation that asked (and on no other).
186
+ const issuer = base.llmTickets
187
+ const llmTickets: LlmTicketIssuer | undefined = issuer && {
188
+ issue: (options: LlmTicketOptions = {}) => issuer.issue({
189
+ ...options,
190
+ onUsage: AsyncResource.bind((report: LlmUsageReport) => context.reportUsage(report.model, report.usage)),
191
+ }),
192
+ }
193
+ const handle = await createPlanner({
194
+ ...base,
195
+ ...(llmTickets ? { llmTickets } : {}),
196
+ resourceLimits: base.resourceLimits ?? DEFAULT_TENANT_PLANNER_RESOURCE_LIMITS,
197
+ // Both come from the tenant, never from the deployment-wide base: a shared label or root would defeat the point.
198
+ cachePartition: context.cachePartition,
199
+ stateRoot: context.tenantRoot,
200
+ })
201
+ handles.push(handle)
202
+ return handle.plannerFactory
203
+ },
204
+ async close() {
205
+ const failures: unknown[] = []
206
+ for (const handle of handles.splice(0)) {
207
+ try { await handle.close() } catch (error) { failures.push(error) }
208
+ }
209
+ if (failures.length === 1) throw failures[0]
210
+ if (failures.length > 1) throw new AggregateError(failures, 'booking_copilot_planner_shutdown_failed')
211
+ },
212
+ }
213
+ }
214
+
215
+ async function startTenantCopilot(
216
+ env: Record<string, string | undefined>,
217
+ dependencies: BookingCopilotStartupDependencies,
218
+ tenancy: BookingTenancy,
219
+ ): Promise<BookingCopilotStartupHandle> {
220
+ if (dependencies.ingressBinding || dependencies.principal) throw new Error('booking_copilot_tenancy_shared_conflict')
221
+ const config = resolveBookingCopilotTenantStartupConfig(env)
222
+ if (config.ingressMode !== undefined && config.ingressMode !== tenancy.ingressMode) throw new Error('booking_copilot_ingress_mode_conflict')
223
+ let server: BookingCopilotServerHandle
224
+ try {
225
+ server = await dependencies.startServer({
226
+ tenancy,
227
+ ingressMode: tenancy.ingressMode,
228
+ host: config.host,
229
+ port: config.port,
230
+ ...(config.artifactId === undefined ? {} : { artifactId: config.artifactId }),
231
+ })
232
+ } catch (error) {
233
+ try { await tenancy.close() } catch (cleanupError) {
234
+ throw new AggregateError([error, cleanupError], 'booking_copilot_startup_and_cleanup_failed')
235
+ }
236
+ throw error
237
+ }
238
+ let closed = false
239
+ return {
240
+ port: server.port,
241
+ async close() {
242
+ if (closed) return
243
+ closed = true
244
+ const failures: unknown[] = []
245
+ // Stop taking turns and drain the ones in flight first; then give the tenants back (planners, then leases).
246
+ try { await server.close() } catch (error) { failures.push(error) }
247
+ try { await tenancy.close() } catch (error) { failures.push(error) }
248
+ if (failures.length === 1) throw failures[0]
249
+ if (failures.length > 1) throw new AggregateError(failures, 'booking_copilot_shutdown_failed')
250
+ },
251
+ }
252
+ }
253
+
100
254
  async function closeAll(
101
255
  server: BookingCopilotServerHandle | undefined,
102
256
  planner: DshEmbeddedBookingPlannerHandle | undefined,
@@ -123,6 +277,7 @@ export async function startBookingCopilotFromEnvironment(
123
277
  env: Record<string, string | undefined> = process.env,
124
278
  dependencies: BookingCopilotStartupDependencies = DEFAULT_DEPENDENCIES,
125
279
  ): Promise<BookingCopilotStartupHandle> {
280
+ if (dependencies.tenancy) return startTenantCopilot(env, dependencies, dependencies.tenancy)
126
281
  const config = resolveBookingCopilotStartupConfig(env)
127
282
  const activeDependencies = dependencies
128
283
  const hasIngressBinding = Boolean(activeDependencies.ingressBinding)
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Multi-tenant seam of the Booking Copilot surface (AaaS A2, docs/rfc/aaas-rfc.md §4.4; threats S-1 to S-4, S-10, S-13, S-14).
3
+ *
4
+ * One request becomes one `TurnScope` here and nowhere else:
5
+ *
6
+ * authenticate -> deriveSeamTenant -> quotas.admitTurn -> router.openTenant -> a runtime over the tenant's guarded ledger
7
+ *
8
+ * The tenant comes from the authenticated caller only. The body, the headers, the tool arguments and the model's output
9
+ * are never inputs to it, and the scope carries no method that takes a tenant. Everything the surface keeps between
10
+ * requests (planner sessions, single-flight decisions) is keyed by the scope, so two tenants whose task handles and
11
+ * request keys collide still never meet. The shared, single-ledger form of the surface (today's deployment) does not
12
+ * use this module and behaves exactly as before.
13
+ */
14
+
15
+ import { AsyncLocalStorage } from 'node:async_hooks'
16
+ import type { MeasuredLlmUsage } from '../llm-price-table.ts'
17
+ import { assertSeamTenant, deriveSeamTenant, InvalidPrincipalError, SeamError, seamTenantKey, UnknownOrganizationError, type AuthenticatedCaller, type CallerAuthenticator, type CallerRequest, type OrganizationKeyring, type SeamTenant } from '../tenant-seam/seam.ts'
18
+ import { QuotaExceededError, type TenantQuotas } from '../tenant-seam/quotas.ts'
19
+ import { InvalidTenantIdError, ReplicaAtCapacityError, RouterClosedError, TenantDrainingError, TenantStateError } from '../tenant-state/errors.ts'
20
+ import { derivePromptCachePartition } from '../tenant-state/ids.ts'
21
+ import type { TenantHandle, TenantRouter } from '../tenant-state/tenant-router.ts'
22
+ import type { BookingCopilotIngressMode, BookingIngressBinding, BookingIngressPrincipal } from './contracts.ts'
23
+ import { BookingCopilotTaskRuntime, type BookingCopilotRuntimeOptions, type BookingPlannerSession, type BookingPlannerSessionFactory } from './runtime.ts'
24
+
25
+ /** What a deployment gives the planner for one tenant. */
26
+ export interface TenantPlannerContext {
27
+ /** The tenant's own directory. Planner working files and session state go here, never beside another tenant's. */
28
+ readonly tenantRoot: string
29
+ /**
30
+ * The tenant's prompt cache partition label (`derivePromptCachePartition`). A planner puts it into every prompt ahead
31
+ * of anything tenant-derived, so a provider's prefix cache is never shared between tenants past the shared instructions.
32
+ */
33
+ readonly cachePartition: string
34
+ /**
35
+ * Reports the measured usage of the planner invocation in progress, priced against the frozen table. A report from
36
+ * outside an invocation of this tenant is dropped, never charged to anyone else.
37
+ */
38
+ reportUsage(model: string, usage: MeasuredLlmUsage): void
39
+ }
40
+
41
+ export interface TenantPlannerProvider {
42
+ /** The planner session factory of one tenant. Called once per tenant and cached. */
43
+ forTenant(context: TenantPlannerContext): Promise<BookingPlannerSessionFactory>
44
+ close(): Promise<void>
45
+ }
46
+
47
+ /** One planner shared by every tenant. Sessions are still keyed by tenant here, but the planner's own files are shared: for fixtures. */
48
+ export function sharedPlannerProvider(factory: BookingPlannerSessionFactory): TenantPlannerProvider {
49
+ return { forTenant: async () => factory, close: async () => undefined }
50
+ }
51
+
52
+ export interface BookingTenancyOptions {
53
+ readonly authenticator: CallerAuthenticator
54
+ readonly keyring: OrganizationKeyring
55
+ readonly router: TenantRouter
56
+ readonly quotas: TenantQuotas
57
+ readonly planners: TenantPlannerProvider
58
+ readonly runtime?: BookingCopilotRuntimeOptions
59
+ /** The in-process BFF identity seam of ADR-23. Absent: ingress turns are refused, as in bound-turn-only. */
60
+ readonly ingressBinding?: BookingIngressBinding
61
+ }
62
+
63
+ /** What the surface needs for one request. Built by the tenancy, or by the server for the shared form. */
64
+ export interface TurnScope {
65
+ /** `<organization>/<hmac tenant>`, or `null` for the shared single-ledger form. */
66
+ readonly key: string | null
67
+ readonly runtime: BookingCopilotTaskRuntime
68
+ readonly plannerFactory: BookingPlannerSessionFactory
69
+ readonly ingress: { readonly binding: BookingIngressBinding; readonly principal: BookingIngressPrincipal } | null
70
+ /** Key of a planner session in the composition-wide map. Equal to the task id in the shared form. */
71
+ sessionKey(taskId: string): string
72
+ /** Key of a decision flight in the composition-wide map. Unchanged in the shared form. */
73
+ flightKey(parts: readonly unknown[]): string
74
+ /** Runs the decision work (the planner await and the ledger write that follows). Tenant form: leased, drained by a handoff, acknowledged only if the lease held. */
75
+ runDecision<T>(work: () => Promise<T>): Promise<T>
76
+ /** True for a failure of the tenant's availability (lease, fence, handoff, store) rather than of the request. */
77
+ isTenantFault(error: unknown): boolean
78
+ /** Frees the concurrency slot. Call exactly once, when the response is over. */
79
+ release(): void
80
+ }
81
+
82
+ /** The handle and the ledger it exposes disagree with the tenant the seam derived: a wiring bug that must never serve a request. */
83
+ export class TenantBindingError extends SeamError {
84
+ constructor() {
85
+ super('GOTRY_SEAM_TENANT_MISMATCH', 'the opened ledger does not belong to the derived tenant')
86
+ }
87
+ }
88
+
89
+ interface InvocationMeter {
90
+ readonly tenantKey: string
91
+ reported: boolean
92
+ }
93
+
94
+ const invocations = new AsyncLocalStorage<InvocationMeter>()
95
+
96
+ function ledgerBytes(handle: TenantHandle): number {
97
+ return handle.run((ledger) => {
98
+ const pages = ledger.db.pragma('page_count', { simple: true }) as number
99
+ const size = ledger.db.pragma('page_size', { simple: true }) as number
100
+ return pages * size
101
+ })
102
+ }
103
+
104
+ export class BookingTenancy {
105
+ readonly ingressMode: BookingCopilotIngressMode
106
+ private readonly options: BookingTenancyOptions
107
+ private readonly planners = new Map<string, Promise<BookingPlannerSessionFactory>>()
108
+
109
+ constructor(options: BookingTenancyOptions) {
110
+ if (typeof options.authenticator?.authenticate !== 'function') throw new Error('booking_copilot_tenancy_authenticator_required')
111
+ if (typeof options.keyring?.keyFor !== 'function') throw new Error('booking_copilot_tenancy_keyring_required')
112
+ if (!options.router || !options.quotas || !options.planners) throw new Error('booking_copilot_tenancy_incomplete')
113
+ if (options.ingressBinding !== undefined && typeof options.ingressBinding.bind !== 'function') throw new Error('booking_copilot_ingress_binding_invalid')
114
+ this.options = options
115
+ this.ingressMode = options.ingressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only'
116
+ }
117
+
118
+ /** The authenticated caller of a request, or `null`. The only place a credential is read. */
119
+ async authenticate(request: CallerRequest): Promise<AuthenticatedCaller | null> {
120
+ return this.options.authenticator.authenticate(request)
121
+ }
122
+
123
+ /**
124
+ * Derives the tenant, admits the turn, and opens the tenant's guarded ledger.
125
+ *
126
+ * @throws QuotaExceededError before any lease or file is touched, when the turn is over a limit
127
+ * @throws InvalidTenantIdError | InvalidPrincipalError | UnknownOrganizationError for a caller the seam cannot place
128
+ * @throws TenantStateError subclasses when the tenant cannot be served here now (owned elsewhere, draining, store down)
129
+ */
130
+ async beginTurn(caller: AuthenticatedCaller): Promise<TurnScope> {
131
+ const { quotas, router } = this.options
132
+ const tenant = deriveSeamTenant(caller, this.options.keyring)
133
+ const permit = quotas.admitTurn(tenant)
134
+ try {
135
+ const handle = await router.openTenant(tenant.organization, tenant.ledgerTenant)
136
+ // Defense in depth: the handle, and the ledger inside it, must be exactly the tenant the seam derived.
137
+ if (handle.organization !== tenant.organization || handle.ledgerTenant !== tenant.ledgerTenant || handle.ledger.tenant !== tenant.ledgerTenant) throw new TenantBindingError()
138
+ quotas.observeLedgerBytes(tenant, ledgerBytes(handle))
139
+ const plannerFactory = await this.plannerFor(tenant)
140
+ const key = seamTenantKey(tenant)
141
+ const runtime = new BookingCopilotTaskRuntime(handle.ledger, this.options.runtime)
142
+ const binding = this.options.ingressBinding
143
+ return {
144
+ key,
145
+ runtime,
146
+ plannerFactory,
147
+ ingress: binding ? { binding, principal: { subject: caller.subject, scope: caller.scope } } : null,
148
+ sessionKey: (taskId) => `${key}\u0000${taskId}`,
149
+ flightKey: (parts) => JSON.stringify([key, ...parts]),
150
+ runDecision: (work) => handle.runAsync(work),
151
+ isTenantFault: (error) => error instanceof TenantStateError,
152
+ release: () => permit.release(),
153
+ }
154
+ } catch (error) {
155
+ permit.release()
156
+ throw error
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Hands every tenant back and stops the planners. The tenancy owns the router and the planner provider it was given:
162
+ * a graceful handoff per tenant (drain, final snapshot, release), then the planners. Resolves when done; a tenant whose
163
+ * final snapshot could not be flushed is reported, because its last writes are then only as durable as the last snapshot.
164
+ */
165
+ async close(): Promise<void> {
166
+ const failures: unknown[] = []
167
+ try { await this.options.planners.close() } catch (error) { failures.push(error) }
168
+ const unflushed = await this.options.router.close().catch((error: unknown) => { failures.push(error); return [] as string[] })
169
+ if (unflushed.length > 0) failures.push(new Error(`booking_copilot_tenant_handoff_unflushed:${unflushed.length}`))
170
+ if (failures.length === 1) throw failures[0]
171
+ if (failures.length > 1) throw new AggregateError(failures, 'booking_copilot_tenancy_close_failed')
172
+ }
173
+
174
+ private plannerFor(tenant: SeamTenant): Promise<BookingPlannerSessionFactory> {
175
+ const seam = assertSeamTenant(tenant)
176
+ const key = seamTenantKey(seam)
177
+ let planner = this.planners.get(key)
178
+ if (!planner) {
179
+ const { quotas, planners, router } = this.options
180
+ const organizationKey = this.options.keyring.keyFor(seam.organization)
181
+ if (organizationKey === null) throw new UnknownOrganizationError()
182
+ planner = planners.forTenant({
183
+ tenantRoot: router.tenantRootOf(seam.organization, seam.ledgerTenant),
184
+ cachePartition: derivePromptCachePartition(organizationKey, seam.ledgerTenant),
185
+ reportUsage: (model, usage) => {
186
+ const meter = invocations.getStore()
187
+ if (!meter || meter.tenantKey !== key) return
188
+ try {
189
+ quotas.recordLlmUsage(seam, model, usage)
190
+ meter.reported = true
191
+ } catch {
192
+ // An unpriced model or malformed counts leave the invocation unproven: it is charged the worst case below.
193
+ }
194
+ },
195
+ }).then((inner) => (task) => this.metered(inner(task), seam, key))
196
+ planner.catch(() => this.planners.delete(key))
197
+ this.planners.set(key, planner)
198
+ }
199
+ return planner
200
+ }
201
+
202
+ /** Wraps a planner session so that every invocation is charged: what it reported, or the worst case if it reported nothing. */
203
+ private metered(session: BookingPlannerSession, tenant: SeamTenant, key: string): BookingPlannerSession {
204
+ const { quotas } = this.options
205
+ return {
206
+ next: async (input) => {
207
+ const meter: InvocationMeter = { tenantKey: key, reported: false }
208
+ try {
209
+ return await invocations.run(meter, () => session.next(input))
210
+ } finally {
211
+ if (!meter.reported) quotas.chargeUnprovenInvocation(tenant)
212
+ }
213
+ },
214
+ ...(session.close ? { close: () => session.close!() } : {}),
215
+ }
216
+ }
217
+ }
218
+
219
+ export interface TenantRejection {
220
+ readonly status: number
221
+ readonly body: { readonly error: Record<string, unknown> }
222
+ readonly retryAfterSeconds?: number
223
+ }
224
+
225
+ /** The typed HTTP answer for a failure raised before the response stream starts. Bodies carry codes, scopes and hints only. */
226
+ export function tenantRejection(error: unknown): TenantRejection {
227
+ if (error instanceof QuotaExceededError) {
228
+ return {
229
+ status: 429,
230
+ body: { error: { code: 'quota_exceeded', scope: error.scope, dimension: error.dimension, retryAfterSeconds: error.retryAfterSeconds } },
231
+ retryAfterSeconds: error.retryAfterSeconds,
232
+ }
233
+ }
234
+ if (error instanceof InvalidTenantIdError || error instanceof InvalidPrincipalError) return { status: 403, body: { error: { code: 'invalid_principal' } } }
235
+ if (error instanceof UnknownOrganizationError) return { status: 403, body: { error: { code: 'organization_not_served' } } }
236
+ if (error instanceof RouterClosedError) return { status: 503, body: { error: { code: 'booking_copilot_shutting_down' } } }
237
+ if (error instanceof TenantDrainingError) return { status: 503, body: { error: { code: 'tenant_draining' } }, retryAfterSeconds: 1 }
238
+ // A full replica frees a slot only when some tenant is handed off, so retrying it soon is not a useful hint; the request belongs on another replica.
239
+ if (error instanceof ReplicaAtCapacityError) return { status: 503, body: { error: { code: 'tenant_unavailable' } } }
240
+ if (error instanceof TenantStateError && !(error instanceof SeamError)) return { status: 503, body: { error: { code: 'tenant_unavailable' } }, retryAfterSeconds: 1 }
241
+ return { status: 500, body: { error: { code: 'tenant_seam_failure' } } }
242
+ }
package/ts/src/index.ts CHANGED
@@ -1894,13 +1894,15 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1894
1894
  fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
1895
1895
  toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
1896
1896
  backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
1897
- ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/任意城市原生+低价日历;hotel 任意目的地原生(cityId 码表不在主链路);匿名公开价)' },
1897
+ ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/多程/任意城市原生+低价日历;hotel 任意目的地原生;匿名公开价)' },
1898
+ legs: { type: 'string', description: '多程段 JSON 数组,仅 kind=flight+ui=gui:[{"from":"香港","to":"迪拜","date":"2026-12-06"},{"from":"迪拜","to":"上海","date":"2026-12-10"}](2–3 段)' },
1899
+ interactiveLogin: { type: 'boolean', description: '仅 ui=gui:登录墙命中时窗口停在携程官方登录页,等用户本人在窗口内完成登录(最多 6 分钟)后自动重查,拿登录态真实数据(行程明细/酒店真实价)。gotry 永不经手密码/验证码/cookie 值' },
1898
1900
  },
1899
1901
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
1900
1902
  async execute(args, _exec) {
1901
1903
  const selectedKind = resolveSessionSearchKind(args)
1902
1904
  if (!selectedKind.ok) return { ok: false, summary: selectedKind.reason } as const
1903
- const q = { ...unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; fromCityCode?: string; toCityCode?: string; cityId?: number | string; country?: string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string; backDate?: string; ui?: string }>(args), kind: selectedKind.kind }
1905
+ const q = { ...unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; fromCityCode?: string; toCityCode?: string; cityId?: number | string; country?: string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string; backDate?: string; ui?: string; legs?: string; interactiveLogin?: boolean }>(args), kind: selectedKind.kind }
1904
1906
  const runSessionEffect = seams.effect ?? interpretEffect
1905
1907
  // ---- Dida 供应商门户(2026-09-09 实装;hotel-be portal integration 迁移线)----
1906
1908
  if (q.kind === 'dida') {
@@ -1974,6 +1976,7 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1974
1976
  effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
1975
1977
  params: {
1976
1978
  to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
1979
+ ...(q.ui === 'gui' && q.interactiveLogin ? { interactiveLoginMs: 360_000 } : {}),
1977
1980
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1978
1981
  },
1979
1982
  })
@@ -2007,11 +2010,34 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
2007
2010
  if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
2008
2011
  return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' } as const
2009
2012
  }
2013
+ // 多程段(仅 gui;严格形状闸——2–3 段、城市非空、日期 YMD、段内日期递增)
2014
+ let legs: Array<{ from: string; to: string; date: string }> | undefined
2015
+ if (q.legs !== undefined) {
2016
+ if (q.ui !== 'gui') return { ok: false, summary: 'legs(多程)仅支持 ui=gui(GUI 车道驱动多程表单)' } as const
2017
+ try {
2018
+ const parsed = JSON.parse(q.legs) as unknown
2019
+ if (!Array.isArray(parsed) || parsed.length < 2 || parsed.length > 3) throw new Error('段数须 2–3')
2020
+ legs = parsed.map((raw: unknown) => {
2021
+ const leg = raw as { from?: unknown; to?: unknown; date?: unknown }
2022
+ if (typeof leg.from !== 'string' || !leg.from.trim() || typeof leg.to !== 'string' || !leg.to.trim() || typeof leg.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(leg.date)) {
2023
+ throw new Error('每段须 {from,to,date(YYYY-MM-DD)}')
2024
+ }
2025
+ return { from: leg.from.trim(), to: leg.to.trim(), date: leg.date }
2026
+ })
2027
+ for (let i = 1; i < legs.length; i += 1) {
2028
+ if (legs[i]!.date <= legs[i - 1]!.date) throw new Error('段日期须递增')
2029
+ }
2030
+ } catch (e) {
2031
+ return { ok: false, summary: `legs 形状非法:${(e as Error).message}` } as const
2032
+ }
2033
+ }
2010
2034
  const itp = await runSessionEffect({
2011
2035
  effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
2012
2036
  params: {
2013
2037
  from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
2014
2038
  ...(q.backDate ? { backDate: q.backDate } : {}),
2039
+ ...(legs ? { legs } : {}),
2040
+ ...(q.ui === 'gui' && q.interactiveLogin ? { interactiveLoginMs: 360_000 } : {}),
2015
2041
  // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
2016
2042
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
2017
2043
  },