@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
|
@@ -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
|
|
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
|
},
|