@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
|
@@ -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/gotry-web-api.ts
CHANGED
|
@@ -12,11 +12,16 @@ import {
|
|
|
12
12
|
removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey,
|
|
13
13
|
sha256Hex, writeFlyaiVerification,
|
|
14
14
|
} from '../capabilities/flyai-config.ts'
|
|
15
|
+
import {
|
|
16
|
+
hbcliCredentialPath, hbcliSetupStatus, readHbcliCredentials, removeHbcliVerification,
|
|
17
|
+
verifyHbcliCandidate, withHbcliCredentials, withoutHbcliCredentials, writeHbcliCredentials, writeHbcliVerification,
|
|
18
|
+
} from '../capabilities/hbcli-config.ts'
|
|
15
19
|
|
|
16
20
|
interface WebOptions {
|
|
17
21
|
stateRoot: string
|
|
18
22
|
homeDir?: string
|
|
19
23
|
env?: NodeJS.ProcessEnv
|
|
24
|
+
hbcliBin?: string
|
|
20
25
|
workspaceForSession: (sessionId: string) => Promise<string | undefined>
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -47,6 +52,7 @@ async function readInput(request: Request): Promise<Record<string, unknown> | Re
|
|
|
47
52
|
export function createGotryWebHandlers(options: WebOptions) {
|
|
48
53
|
const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env
|
|
49
54
|
const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir)
|
|
55
|
+
const hbPaths = { homeDir, env, hbcliBin: options.hbcliBin }
|
|
50
56
|
let mutations: Promise<unknown> = Promise.resolve()
|
|
51
57
|
const status = () => {
|
|
52
58
|
const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir)
|
|
@@ -92,7 +98,48 @@ export function createGotryWebHandlers(options: WebOptions) {
|
|
|
92
98
|
}
|
|
93
99
|
return json(status())
|
|
94
100
|
}
|
|
101
|
+
async function mutateHbcli(input: Record<string, unknown>, signal: AbortSignal): Promise<Response> {
|
|
102
|
+
if (signal.aborted) return rejection('操作已取消。', 409)
|
|
103
|
+
if (!hbcliSetupStatus(hbPaths).writable) return rejection('当前凭证来自环境变量,或本机配置不可安全写入。', 409)
|
|
104
|
+
const before = readHbcliCredentials(hbPaths)
|
|
105
|
+
if (!before.ok) return rejection('原配置损坏,已保留原文件,请先修复。', 409)
|
|
106
|
+
if (input.action === 'clear') {
|
|
107
|
+
if (!writeHbcliCredentials(withoutHbcliCredentials(before.data), hbPaths)) return rejection('无法清除配置,请检查本机文件及权限。', 409)
|
|
108
|
+
removeHbcliVerification(homeDir)
|
|
109
|
+
return json(hbcliSetupStatus(hbPaths))
|
|
110
|
+
}
|
|
111
|
+
const appKey = typeof input.appKey === 'string' ? input.appKey.trim() : ''
|
|
112
|
+
const appSecret = typeof input.appSecret === 'string' ? input.appSecret.trim() : ''
|
|
113
|
+
if ([appKey, appSecret].some(value => !value || value.length > 2048 || /[\r\n\0]/.test(value))) return rejection('请填写 HotelByte 提供的 App Key 和 App Secret。')
|
|
114
|
+
const verified = await verifyHbcliCandidate(appKey, appSecret, hbPaths, signal)
|
|
115
|
+
if (signal.aborted || verified.verdict === 'error' || verified.verdict === 'cancelled') {
|
|
116
|
+
return json({ ok: false, error: verified.error ?? '验证已取消,旧配置保留。' }, 422)
|
|
117
|
+
}
|
|
118
|
+
const current = readHbcliCredentials(hbPaths)
|
|
119
|
+
if (!hbcliSetupStatus(hbPaths).writable || !current.ok || current.bytes !== before.bytes) return rejection('配置在验证期间发生变化,请重新读取后再试。', 409)
|
|
120
|
+
const next = withHbcliCredentials(before.data, appKey, appSecret)
|
|
121
|
+
if (!writeHbcliCredentials(next, hbPaths)) return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409)
|
|
122
|
+
if (!writeHbcliVerification(next, verified.verdict, hbPaths)) {
|
|
123
|
+
const restored = before.bytes === undefined
|
|
124
|
+
? (() => { try { rmSync(hbcliCredentialPath(hbPaths)); return true } catch { return false } })()
|
|
125
|
+
: writeHbcliCredentials(before.data, hbPaths)
|
|
126
|
+
return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,请检查本机文件。', 409)
|
|
127
|
+
}
|
|
128
|
+
return json(hbcliSetupStatus(hbPaths))
|
|
129
|
+
}
|
|
95
130
|
return {
|
|
131
|
+
async hbcli(request: Request): Promise<Response> {
|
|
132
|
+
if (request.method === 'GET') return json(hbcliSetupStatus(hbPaths))
|
|
133
|
+
if (request.method !== 'POST') return rejection('不支持此操作。', 405)
|
|
134
|
+
const input = await readInput(request)
|
|
135
|
+
if (input instanceof Response) return input
|
|
136
|
+
if (!['save', 'clear'].includes(String(input.action))
|
|
137
|
+
|| Object.keys(input).some(key => !['action', 'appKey', 'appSecret'].includes(key))
|
|
138
|
+
|| (input.action === 'clear' && ('appKey' in input || 'appSecret' in input))) return rejection('请求无效。')
|
|
139
|
+
const operation = mutations.then(() => mutateHbcli(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500))
|
|
140
|
+
mutations = operation.then(() => undefined)
|
|
141
|
+
return operation
|
|
142
|
+
},
|
|
96
143
|
async flyai(request: Request): Promise<Response> {
|
|
97
144
|
if (request.method === 'GET') return json(status())
|
|
98
145
|
if (request.method !== 'POST') return rejection('不支持此操作。', 405)
|
|
@@ -121,11 +168,11 @@ export function createGotryWebHandlers(options: WebOptions) {
|
|
|
121
168
|
}
|
|
122
169
|
}
|
|
123
170
|
|
|
124
|
-
export function registerGotryWebApi(ctx: Context, stateRoot: string): void {
|
|
171
|
+
export function registerGotryWebApi(ctx: Context, stateRoot: string, hbcliBin?: string): void {
|
|
125
172
|
if (typeof ctx.inject !== 'function') return
|
|
126
173
|
ctx.inject(['connection', 'sessions'], scope => {
|
|
127
174
|
const connection = scope.get('connection') as HostConnectionHandle
|
|
128
|
-
const handlers = createGotryWebHandlers({ stateRoot, workspaceForSession: async sessionId => {
|
|
175
|
+
const handlers = createGotryWebHandlers({ stateRoot, hbcliBin, workspaceForSession: async sessionId => {
|
|
129
176
|
const id = sessionId as Parameters<typeof scope.sessions.get>[0]
|
|
130
177
|
const live = scope.sessions.get(id)?.header
|
|
131
178
|
const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId)
|
|
@@ -135,6 +182,8 @@ export function registerGotryWebApi(ctx: Context, stateRoot: string): void {
|
|
|
135
182
|
// it. Exact routes have one body mode, so reads and bounded writes differ.
|
|
136
183
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }))
|
|
137
184
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }))
|
|
185
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli', methods: ['GET'], requestBody: 'buffered', fetch: handlers.hbcli }))
|
|
186
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.hbcli }))
|
|
138
187
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }))
|
|
139
188
|
})
|
|
140
189
|
}
|