diff --git a/CLAUDE.md b/CLAUDE.md index e88eed3..3c3f5ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,13 +1,21 @@ # CLAUDE.md +## Setup + +Before running any commands, install dependencies with: + +```bash +yarn install +``` + ## Verification After every code change, the following commands must all pass before considering the work complete: ```bash -pnpm lint -pnpm format:check -pnpm build +yarn lint +yarn format:check +yarn build ``` Run these from the repository root. Use `npx nx` to target individual projects (e.g., `npx nx build browser`). diff --git a/docs/enclave/api-reference/enclavejs-broker.mdx b/docs/enclave/api-reference/enclavejs-broker.mdx index 2ab9266..34483b8 100644 --- a/docs/enclave/api-reference/enclavejs-broker.mdx +++ b/docs/enclave/api-reference/enclavejs-broker.mdx @@ -193,6 +193,93 @@ Submit tool execution result. } ``` +### GET /code/actions + +Returns the current action catalog derived from connected OpenAPI sources. + +**Response:** +```ts +interface CatalogResponse { + /** Available actions from all connected OpenAPI sources */ + actions: CatalogAction[]; + /** Connected OpenAPI service descriptors */ + services: CatalogService[]; + /** Catalog version — changes when actions are added or removed */ + version: string; +} + +interface CatalogAction { + /** Tool name (e.g., "user-service_listUsers") */ + name: string; + /** Human-readable description from the OpenAPI spec */ + description?: string; + /** JSON Schema for the tool's input parameters */ + inputSchema?: Record; + /** Name of the service this action belongs to */ + service: string; + /** Tags from the OpenAPI operation */ + tags?: string[]; + /** Whether the operation is deprecated */ + deprecated?: boolean; +} + +interface CatalogService { + /** Service name (from OpenApiSourceConfig.name) */ + name: string; + /** Internal spec URL */ + specUrl: string; + /** ISO 8601 timestamp of the last successful spec poll */ + lastUpdated: string; + /** Number of actions from this service */ + actionCount: number; +} +``` + +**Version semantics:** The `version` field is a deterministic hash of all source spec hashes. It changes whenever an OpenAPI spec is polled with additions or removals. Consumers can compare version strings to detect catalog changes. + +**Example:** +```bash +curl http://localhost:3001/code/actions +``` + +```json +{ + "actions": [ + { + "name": "user-service_listUsers", + "description": "List all users", + "service": "user-service" + }, + { + "name": "user-service_getUser", + "description": "Get user by ID", + "service": "user-service" + } + ], + "services": [ + { + "name": "user-service", + "specUrl": "", + "lastUpdated": "2026-03-31T12:00:00.000Z", + "actionCount": 2 + } + ], + "version": "a1b2c3d4..." +} +``` + +**Wiring:** Use `CatalogHandler` to register this route: +```ts +import { CatalogHandler } from '@enclave-vm/broker'; + +const catalog = new CatalogHandler(toolRegistry, openApiSources); +for (const route of catalog.getRoutes()) { + app[route.method.toLowerCase()](route.path, route.handler); +} +``` + +The types `CatalogAction`, `CatalogService`, `CatalogResponse`, and `CatalogHandler` are all exported from the `@enclave-vm/broker` package. + ### GET /health Health check endpoint. diff --git a/libs/broker/package.json b/libs/broker/package.json index 65563ee..389a631 100644 --- a/libs/broker/package.json +++ b/libs/broker/package.json @@ -38,7 +38,7 @@ "@enclave-vm/types": "2.11.0", "@enclave-vm/stream": "2.11.0", "@enclave-vm/core": "2.11.0", - "minimatch": "^10.1.1", + "minimatch": "^10.2.5", "zod": "^4.3.6" } } diff --git a/libs/broker/src/broker-session.ts b/libs/broker/src/broker-session.ts index 644ade3..46a3f8d 100644 --- a/libs/broker/src/broker-session.ts +++ b/libs/broker/src/broker-session.ts @@ -6,8 +6,14 @@ * @packageDocumentation */ -import type { SessionId, StreamEvent, SessionLimits } from '@enclave-vm/types'; -import { generateSessionId, generateCallId, DEFAULT_SESSION_LIMITS } from '@enclave-vm/types'; +import type { SessionId, StreamEvent, SessionLimits, ErrorPayload } from '@enclave-vm/types'; +import { + generateSessionId, + generateCallId, + DEFAULT_SESSION_LIMITS, + EventType, + PROTOCOL_VERSION, +} from '@enclave-vm/types'; import { SessionEmitter, createSessionEmitter, Enclave } from '@enclave-vm/core'; import type { SessionStateValue, SessionFinalResult, CreateEnclaveOptions, ExecutionStats } from '@enclave-vm/core'; import type { ToolRegistry, ToolContext } from './tool-registry'; @@ -59,6 +65,9 @@ export class BrokerSession { private toolCallCount = 0; private stdoutBytes = 0; private heartbeatInterval: ReturnType | null = null; + private deadlineTimer: ReturnType | null = null; + private _deadlineExceeded = false; + private readonly partialErrors: ErrorPayload[] = []; constructor(toolRegistry: ToolRegistry, config: BrokerSessionConfig = {}) { this.sessionId = config.sessionId ?? generateSessionId(); @@ -146,6 +155,49 @@ export class BrokerSession { }, heartbeatMs); } + // Set up deadline timer if configured (compute remaining from createdAt epoch) + const deadlineMs = this.limits.deadlineMs; + if (deadlineMs > 0) { + const remaining = deadlineMs - (Date.now() - this.createdAt); + if (remaining > 0) { + this.deadlineTimer = setTimeout(() => { + this._deadlineExceeded = true; + this.cancel(`Deadline exceeded: ${deadlineMs}ms budget`); + }, remaining); + } else { + // Deadline already passed before execution started + this._deadlineExceeded = true; + this.stopHeartbeat(); + this._state = 'failed'; + const elapsed = Date.now() - this.createdAt; + this.emitter.emit( + this.makeCustomEvent(EventType.DeadlineExceeded, { elapsedMs: elapsed, budgetMs: deadlineMs }), + ); + this.emitter.emitFinalError( + { code: 'DEADLINE_EXCEEDED', message: 'Deadline exceeded before execution' }, + { durationMs: elapsed, toolCallCount: 0, stdoutBytes: 0 }, + ); + const finalResult: SessionFinalResult = { + success: false, + error: { + message: 'Deadline exceeded before execution', + name: 'Error', + code: 'DEADLINE_EXCEEDED', + }, + stats: { + duration: elapsed, + toolCallCount: 0, + iterationCount: 0, + startTime: this.createdAt, + endTime: Date.now(), + }, + finalState: 'failed', + }; + this.executionPromise = Promise.resolve(finalResult); + return finalResult; + } + } + // Transition to running this._state = 'running'; @@ -175,14 +227,36 @@ export class BrokerSession { const endTime = Date.now(); const stats = this.buildStats(startTime, endTime); + const eventStats = { + durationMs: stats.duration, + toolCallCount: this.toolCallCount, + stdoutBytes: this.stdoutBytes, + }; - if (enclaveResult.success) { + // Session was cancelled/terminated while enclave was running + if (this.isTerminal()) { + if (this._deadlineExceeded) { + this.emitter.emit( + this.makeCustomEvent(EventType.DeadlineExceeded, { + elapsedMs: Date.now() - this.createdAt, + budgetMs: this.limits.deadlineMs, + }), + ); + } + const cancelError = { + code: this._deadlineExceeded ? 'DEADLINE_EXCEEDED' : 'SESSION_CANCELLED', + message: this._deadlineExceeded ? 'Deadline exceeded' : 'Session was cancelled', + }; + this.emitter.emitFinalError(cancelError, eventStats, this.getPartialErrors()); + result = { + success: false, + error: { message: cancelError.message, name: 'Error', code: cancelError.code }, + stats, + finalState: this._deadlineExceeded ? 'failed' : 'cancelled', + }; + } else if (enclaveResult.success) { this._state = 'completed'; - this.emitter.emitFinalSuccess(enclaveResult.value, { - durationMs: stats.duration, - toolCallCount: this.toolCallCount, - stdoutBytes: this.stdoutBytes, - }); + this.emitter.emitFinalSuccess(enclaveResult.value, eventStats, this.getPartialErrors()); result = { success: true, value: enclaveResult.value, @@ -195,11 +269,7 @@ export class BrokerSession { code: enclaveResult.error?.code ?? 'EXECUTION_ERROR', message: enclaveResult.error?.message ?? 'Execution failed', }; - this.emitter.emitFinalError(errorInfo, { - durationMs: stats.duration, - toolCallCount: this.toolCallCount, - stdoutBytes: this.stdoutBytes, - }); + this.emitter.emitFinalError(errorInfo, eventStats, this.getPartialErrors()); result = { success: false, error: { @@ -215,17 +285,36 @@ export class BrokerSession { const endTime = Date.now(); const stats = this.buildStats(startTime, endTime); const err = error instanceof Error ? error : new Error(String(error)); + const eventStats = { + durationMs: stats.duration, + toolCallCount: this.toolCallCount, + stdoutBytes: this.stdoutBytes, + }; - this._state = 'failed'; + // Emit deadline exceeded if that's why we were cancelled + if (this._deadlineExceeded) { + this.emitter.emit( + this.makeCustomEvent(EventType.DeadlineExceeded, { + elapsedMs: Date.now() - this.createdAt, + budgetMs: this.limits.deadlineMs, + }), + ); + } + + if (!this.isTerminal()) { + this._state = 'failed'; + } + + const isCancelled = this._state === 'cancelled'; const errorInfo = { - code: (err as Error & { code?: string }).code ?? 'EXECUTION_ERROR', + code: isCancelled + ? this._deadlineExceeded + ? 'DEADLINE_EXCEEDED' + : 'SESSION_CANCELLED' + : ((err as Error & { code?: string }).code ?? 'EXECUTION_ERROR'), message: err.message, }; - this.emitter.emitFinalError(errorInfo, { - durationMs: stats.duration, - toolCallCount: this.toolCallCount, - stdoutBytes: this.stdoutBytes, - }); + this.emitter.emitFinalError(errorInfo, eventStats, this.getPartialErrors()); result = { success: false, error: { @@ -234,34 +323,125 @@ export class BrokerSession { code: errorInfo.code, }, stats, - finalState: 'failed', + finalState: isCancelled ? 'cancelled' : 'failed', }; } finally { this.stopHeartbeat(); + this.stopDeadline(); } return result; } + /** + * Emit a tool progress event. + */ + emitToolProgress( + callId: string, + phase: 'connecting' | 'sending' | 'receiving' | 'processing', + elapsedMs: number, + bytesReceived?: number, + totalBytes?: number, + ): void { + this.emitter.emit( + this.makeCustomEvent(EventType.ToolProgress, { + callId, + phase, + elapsedMs, + bytesReceived, + totalBytes, + }), + ); + } + + /** + * Emit a partial result event. + */ + emitPartialResult(path: string[], data?: unknown, error?: ErrorPayload, hasNext = true): void { + if (error) { + this.partialErrors.push(error); + } + this.emitter.emit( + this.makeCustomEvent(EventType.PartialResult, { + path, + data, + error, + hasNext, + }), + ); + } + + /** + * Get accumulated partial errors. + */ + getPartialErrors(): ErrorPayload[] { + return [...this.partialErrors]; + } + /** * Execute a tool call directly */ private async executeTool(toolName: string, args: Record): Promise { const callId = generateCallId(); + const toolStartTime = Date.now(); + + // Check session deadline before emitting any events + if (this.limits.deadlineMs > 0) { + const elapsed = Date.now() - this.createdAt; + const remaining = this.limits.deadlineMs - elapsed; + if (remaining <= 0) { + this._deadlineExceeded = true; + const err = new Error('Deadline exceeded before tool execution'); + (err as Error & { code?: string }).code = 'DEADLINE_EXCEEDED'; + throw err; + } + } // Emit tool call event this.emitter.emitToolCall(callId, toolName, args); this.toolCallCount++; + // Emit initial progress + this.emitToolProgress(callId, 'connecting', 0); + + // Compute per-tool timeout, capped by remaining session budget + let toolTimeout = this.limits.perToolDeadlineMs; + if (this.limits.deadlineMs > 0) { + const remaining = this.limits.deadlineMs - (Date.now() - this.createdAt); + toolTimeout = Math.min(toolTimeout, remaining); + } + + // Create per-tool AbortController that races the session signal and a timer + const toolAbort = new AbortController(); + let toolTimer: ReturnType | null = null; + if (toolTimeout > 0) { + toolTimer = setTimeout(() => toolAbort.abort(), toolTimeout); + } + // Propagate session-level abort to per-tool controller + const onSessionAbort = () => toolAbort.abort(); + this.abortController.signal.addEventListener('abort', onSessionAbort); + if (this.abortController.signal.aborted) { + toolAbort.abort(); + } + // Execute through registry const context: ToolContext = { sessionId: this.sessionId, callId, secrets: {}, // Resolved by registry - signal: this.abortController.signal, + signal: toolAbort.signal, }; - const result = await this.toolRegistry.execute(toolName, args, context); + // Emit processing progress + this.emitToolProgress(callId, 'processing', Date.now() - toolStartTime); + + let result; + try { + result = await this.toolRegistry.execute(toolName, args, context); + } finally { + if (toolTimer) clearTimeout(toolTimer); + this.abortController.signal.removeEventListener('abort', onSessionAbort); + } // Emit tool result event this.emitter.emitToolResultApplied(callId); @@ -269,12 +449,30 @@ export class BrokerSession { if (result.success) { return result.value; } else { + // Check cancelOnFirstError + if (this.limits.cancelOnFirstError) { + this.abortController.abort(); + } + const error = new Error(result.error?.message ?? 'Tool call failed'); (error as Error & { code?: string }).code = result.error?.code; throw error; } } + /** + * Create a custom event with proper base fields and unique sequence number. + */ + private makeCustomEvent(type: string, payload: Record): StreamEvent { + return { + protocolVersion: PROTOCOL_VERSION, + sessionId: this.sessionId, + seq: this.emitter.nextSeq(), + type, + payload, + } as unknown as StreamEvent; + } + /** * Build execution stats */ @@ -297,19 +495,40 @@ export class BrokerSession { } this.stopHeartbeat(); + this.stopDeadline(); this.abortController.abort(); this._state = 'cancelled'; - this.emitter.emitError('SESSION_CANCELLED', reason ?? 'Session was cancelled', false); - - this.emitter.emitFinalError( - { code: 'SESSION_CANCELLED', message: reason ?? 'Session was cancelled' }, - { - durationMs: Date.now() - this.createdAt, - toolCallCount: this.toolCallCount, - stdoutBytes: this.stdoutBytes, - }, - ); + // When execution is running, runExecution() will emit the final event + // after catching the abort. Only emit directly if no execution is in progress. + if (!this.executionPromise) { + this.emitter.emitError('SESSION_CANCELLED', reason ?? 'Session was cancelled', false); + this.emitter.emitFinalError( + { code: 'SESSION_CANCELLED', message: reason ?? 'Session was cancelled' }, + { + durationMs: Date.now() - this.createdAt, + toolCallCount: this.toolCallCount, + stdoutBytes: this.stdoutBytes, + }, + this.getPartialErrors(), + ); + this.executionPromise = Promise.resolve({ + success: false, + error: { + message: reason ?? 'Session was cancelled', + name: 'Error', + code: 'SESSION_CANCELLED', + }, + stats: { + duration: Date.now() - this.createdAt, + toolCallCount: this.toolCallCount, + iterationCount: 0, + startTime: this.createdAt, + endTime: Date.now(), + }, + finalState: 'cancelled', + }); + } } /** @@ -360,11 +579,22 @@ export class BrokerSession { } } + /** + * Stop deadline timer + */ + private stopDeadline(): void { + if (this.deadlineTimer) { + clearTimeout(this.deadlineTimer); + this.deadlineTimer = null; + } + } + /** * Dispose of the session and its resources */ dispose(): void { this.stopHeartbeat(); + this.stopDeadline(); this.abortController.abort(); this.enclave.dispose(); } diff --git a/libs/broker/src/broker.ts b/libs/broker/src/broker.ts index 4806afe..90e75df 100644 --- a/libs/broker/src/broker.ts +++ b/libs/broker/src/broker.ts @@ -14,6 +14,8 @@ import { SessionManager, createSessionManager } from './session-manager'; import type { SessionManagerConfig, SessionInfo } from './session-manager'; import { BrokerSession } from './broker-session'; import type { BrokerSessionConfig } from './broker-session'; +import { OpenApiSource } from './openapi/openapi-source'; +import type { OpenApiSourceConfig } from './openapi/openapi-source'; /** * Broker configuration @@ -83,6 +85,7 @@ export class Broker { private readonly toolRegistry: ToolRegistry; private readonly sessionManager: SessionManager; private readonly config: BrokerConfig; + private readonly openApiSources: OpenApiSource[] = []; private disposed = false; constructor(config: BrokerConfig = {}) { @@ -163,6 +166,43 @@ export class Broker { return this.toolRegistry.unregister(name); } + // ============================================================================ + // OpenAPI Sources + // ============================================================================ + + /** + * Register an OpenAPI source for automatic tool generation and polling. + * + * @param config - OpenAPI source configuration + * @returns The created OpenApiSource instance + * + * @example + * ```typescript + * const source = broker.openapi({ + * name: 'user-service', + * url: 'https://api.example.com/openapi.json', + * baseUrl: 'https://api.example.com', + * intervalMs: 60000, + * auth: { type: 'bearer', token: process.env.API_TOKEN }, + * }); + * + * await source.start(); + * ``` + */ + openapi(config: OpenApiSourceConfig): OpenApiSource { + this.ensureNotDisposed(); + const source = new OpenApiSource(this.toolRegistry, config); + this.openApiSources.push(source); + return source; + } + + /** + * Get all registered OpenAPI sources. + */ + getOpenApiSources(): readonly OpenApiSource[] { + return this.openApiSources; + } + // ============================================================================ // Secret Management // ============================================================================ @@ -332,6 +372,13 @@ export class Broker { } this.disposed = true; + + // Dispose OpenAPI sources + for (const source of this.openApiSources) { + source.dispose(); + } + this.openApiSources.length = 0; + await this.sessionManager.dispose(); this.toolRegistry.clear(); this.toolRegistry.clearSecrets(); diff --git a/libs/broker/src/index.ts b/libs/broker/src/index.ts index 2727912..63f2f1b 100644 --- a/libs/broker/src/index.ts +++ b/libs/broker/src/index.ts @@ -45,3 +45,21 @@ export type { // Event Filtering export { EventFilter, createEventFilter, createTypeFilter, createContentFilter } from './event-filter'; export type { EventFilterOptions } from './event-filter'; + +// OpenAPI Integration +export { OpenApiSpecPoller, OpenApiToolLoader, OpenApiSource, CatalogHandler } from './openapi'; +export type { + OpenApiPollerConfig, + ChangeDetectionMode, + PollerRetryConfig, + OpenApiPollerEvents, + LoaderOptions, + UpstreamAuth, + OpenApiSourceConfig, + OpenApiSourceStats, + ToolsUpdatedEvent, + SourceHealthStatus, + CatalogAction, + CatalogService, + CatalogResponse, +} from './openapi'; diff --git a/libs/broker/src/openapi/catalog-handler.ts b/libs/broker/src/openapi/catalog-handler.ts new file mode 100644 index 0000000..5b16e94 --- /dev/null +++ b/libs/broker/src/openapi/catalog-handler.ts @@ -0,0 +1,128 @@ +/** + * Catalog Handler + * + * HTTP handler for GET /code/actions endpoint. + * Returns the current action catalog derived from OpenAPI sources. + * + * @packageDocumentation + */ + +import type { ToolRegistry } from '../tool-registry'; +import type { OpenApiSource } from './openapi-source'; +import type { BrokerRequest, BrokerResponse } from '../http/types'; + +/** + * Action descriptor in the catalog. + */ +export interface CatalogAction { + name: string; + description?: string; + inputSchema?: Record; + service: string; + tags?: string[]; + deprecated?: boolean; +} + +/** + * Service descriptor in the catalog. + */ +export interface CatalogService { + name: string; + specUrl: string; + lastUpdated: string; + actionCount: number; +} + +/** + * Full catalog response. + */ +export interface CatalogResponse { + actions: CatalogAction[]; + services: CatalogService[]; + version: string; +} + +/** + * HTTP handler for the action catalog endpoint. + */ +export class CatalogHandler { + private readonly toolRegistry: ToolRegistry; + private readonly sources: OpenApiSource[]; + + constructor(toolRegistry: ToolRegistry, sources: OpenApiSource[]) { + this.toolRegistry = toolRegistry; + this.sources = sources; + } + + /** + * Get route definitions for the catalog endpoint. + */ + getRoutes(): Array<{ + method: string; + path: string; + handler: (req: BrokerRequest, res: BrokerResponse) => Promise; + }> { + return [ + { + method: 'GET', + path: '/code/actions', + handler: this.handleGetActions.bind(this), + }, + ]; + } + + /** + * Handle GET /code/actions request. + */ + private async handleGetActions(_req: BrokerRequest, res: BrokerResponse): Promise { + const catalog = this.buildCatalog(); + res.status(200); + res.json(catalog); + } + + /** + * Build the full catalog response. + */ + buildCatalog(): CatalogResponse { + const actions: CatalogAction[] = []; + const services: CatalogService[] = []; + + // Build tool→service mapping and service info from OpenAPI sources + const toolToService = new Map(); + for (const source of this.sources) { + const stats = source.getStats(); + const serviceName = source.getName(); + services.push({ + name: serviceName, + specUrl: '', // URL is internal to the source + lastUpdated: stats.lastUpdate, + actionCount: stats.toolCount, + }); + for (const toolName of source.getToolNames()) { + toolToService.set(toolName, serviceName); + } + } + + // Build actions from tool registry + const toolNames = this.toolRegistry.list(); + for (const name of toolNames) { + const tool = this.toolRegistry.get(name); + if (!tool) continue; + + actions.push({ + name: tool.name, + description: tool.description, + service: toolToService.get(name) ?? 'default', + }); + } + + // Version is a deterministic hash of all source hashes (sorted for stability) + const versionParts = this.sources + .map((s) => s.getStats().specHash) + .sort() + .join(':'); + const version = versionParts || 'empty'; + + return { actions, services, version }; + } +} diff --git a/libs/broker/src/openapi/hash-utils.ts b/libs/broker/src/openapi/hash-utils.ts new file mode 100644 index 0000000..bf910a3 --- /dev/null +++ b/libs/broker/src/openapi/hash-utils.ts @@ -0,0 +1,20 @@ +/** + * Cross-platform SHA-256 hashing utility. + * + * Uses Web Crypto API (crypto.subtle) which is available in both + * Node.js 18+ and modern browsers. + * + * @packageDocumentation + */ + +/** + * Compute SHA-256 hex digest of a string. + */ +export async function sha256Hex(data: string): Promise { + const encoded = new TextEncoder().encode(data); + const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); + const hashArray = new Uint8Array(hashBuffer); + return Array.from(hashArray) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/libs/broker/src/openapi/index.ts b/libs/broker/src/openapi/index.ts new file mode 100644 index 0000000..4b9e89e --- /dev/null +++ b/libs/broker/src/openapi/index.ts @@ -0,0 +1,22 @@ +/** + * OpenAPI integration for the Enclave broker. + * + * @packageDocumentation + */ + +export { OpenApiSpecPoller } from './openapi-spec-poller'; +export type { + OpenApiPollerConfig, + ChangeDetectionMode, + PollerRetryConfig, + OpenApiPollerEvents, +} from './openapi-spec-poller'; + +export { OpenApiToolLoader } from './openapi-tool-loader'; +export type { LoaderOptions, UpstreamAuth } from './openapi-tool-loader'; + +export { OpenApiSource } from './openapi-source'; +export type { OpenApiSourceConfig, OpenApiSourceStats, ToolsUpdatedEvent, SourceHealthStatus } from './openapi-source'; + +export { CatalogHandler } from './catalog-handler'; +export type { CatalogAction, CatalogService, CatalogResponse } from './catalog-handler'; diff --git a/libs/broker/src/openapi/openapi-source.ts b/libs/broker/src/openapi/openapi-source.ts new file mode 100644 index 0000000..cb152a9 --- /dev/null +++ b/libs/broker/src/openapi/openapi-source.ts @@ -0,0 +1,281 @@ +/** + * OpenAPI Source + * + * Orchestrator that ties together the spec poller, tool loader, and broker. + * Handles diff-based tool updates when specs change. + * + * @packageDocumentation + */ + +import { EventEmitter } from 'events'; +import { OpenApiSpecPoller } from './openapi-spec-poller'; +import type { OpenApiPollerConfig } from './openapi-spec-poller'; +import { OpenApiToolLoader } from './openapi-tool-loader'; +import type { LoaderOptions, UpstreamAuth } from './openapi-tool-loader'; +import type { ToolRegistry } from '../tool-registry'; + +/** + * Health status for the OpenAPI source. + */ +export type SourceHealthStatus = 'healthy' | 'unhealthy' | 'unknown'; + +/** + * Configuration for an OpenAPI source. + */ +export interface OpenApiSourceConfig extends OpenApiPollerConfig { + /** Service name (e.g., 'user-service') */ + name: string; + /** Upstream API base URL */ + baseUrl: string; + /** Tool loader options */ + loaderOptions?: LoaderOptions; + /** Upstream authentication */ + auth?: UpstreamAuth; +} + +/** + * Statistics for the OpenAPI source. + */ +export interface OpenApiSourceStats { + toolCount: number; + lastUpdate: string; + specHash: string; + pollHealth: SourceHealthStatus; +} + +/** + * Tool update event data. + */ +export interface ToolsUpdatedEvent { + added: string[]; + removed: string[]; + updated: string[]; +} + +/** + * Orchestrator that ties together spec polling, tool loading, and broker registration. + */ +export class OpenApiSource extends EventEmitter { + private readonly toolRegistry: ToolRegistry; + private readonly config: OpenApiSourceConfig; + private poller: OpenApiSpecPoller | null = null; + private previousToolNames: Set = new Set(); + private lastUpdate = ''; + private specHash = ''; + private health: SourceHealthStatus = 'unknown'; + private disposed = false; + private _syncLock: Promise = Promise.resolve(); + + constructor(toolRegistry: ToolRegistry, config: OpenApiSourceConfig) { + super(); + this.toolRegistry = toolRegistry; + this.config = config; + } + + /** + * Initial load + start polling. + */ + async start(): Promise { + if (this.disposed) throw new Error('OpenApiSource is disposed'); + + // Initial load + await this.refresh(); + + // Start polling + this.poller = new OpenApiSpecPoller(this.config); + + this.poller.on('changed', (spec: string) => { + this._syncLock = this._syncLock.then(async () => { + try { + const parsed = JSON.parse(spec) as Record; + await this.syncTools(parsed); + this.health = 'healthy'; + } catch (error) { + this.emit('error', error instanceof Error ? error : new Error(String(error))); + } + }); + }); + + this.poller.on('unhealthy', (failures: number) => { + this.health = 'unhealthy'; + this.emit('unhealthy', failures); + }); + + this.poller.on('recovered', () => { + this.health = 'healthy'; + this.emit('recovered'); + }); + + this.poller.on('pollError', (error: Error) => { + this.emit('error', error); + }); + + this.poller.start(); + } + + /** + * Stop polling. + */ + stop(): void { + if (this.poller) { + this.poller.stop(); + } + } + + /** + * Manual refresh (for webhook triggers). + */ + async refresh(): Promise { + if (this.disposed) throw new Error('OpenApiSource is disposed'); + + const loader = await OpenApiToolLoader.fromURL( + this.config.url, + { + ...this.config.loaderOptions, + baseUrl: this.config.baseUrl, + sourceName: this.config.name, + }, + this.config.auth, + ); + + const tools = loader.getTools(); + const newToolNames = loader.getToolNames(); + const oldToolNames = this.previousToolNames; + + // Register all tools + for (const tool of tools) { + if (this.toolRegistry.has(tool.name)) { + this.toolRegistry.replace(tool); + } else { + this.toolRegistry.register(tool); + } + } + + // Remove tools that no longer exist + for (const name of oldToolNames) { + if (!newToolNames.has(name)) { + this.toolRegistry.unregister(name); + } + } + + this.previousToolNames = newToolNames; + this.specHash = loader.getSpecHash(); + this.lastUpdate = new Date().toISOString(); + this.health = 'healthy'; + + // Compute diff against the old set (before overwrite) + const added = [...newToolNames].filter((n) => !oldToolNames.has(n)); + const removed = [...oldToolNames].filter((n) => !newToolNames.has(n)); + + this.emit('toolsUpdated', { + added, + removed, + updated: [...newToolNames].filter((n) => oldToolNames.has(n)), + }); + + if (added.length > 0 || removed.length > 0) { + this.emit('catalogChanged', { + version: this.specHash, + addedActions: added, + removedActions: removed, + }); + } + } + + /** + * Sync tools based on a new spec (diff-based update). + */ + private async syncTools(spec: Record): Promise { + const loader = await OpenApiToolLoader.fromSpec( + spec, + { + ...this.config.loaderOptions, + baseUrl: this.config.baseUrl, + sourceName: this.config.name, + }, + this.config.auth, + ); + + const newTools = loader.getTools(); + const newToolNames = loader.getToolNames(); + + const added: string[] = []; + const removed: string[] = []; + const updated: string[] = []; + + // Find added and updated tools + for (const tool of newTools) { + if (this.previousToolNames.has(tool.name)) { + this.toolRegistry.replace(tool); + updated.push(tool.name); + } else { + this.toolRegistry.register(tool); + added.push(tool.name); + } + } + + // Find removed tools + for (const name of this.previousToolNames) { + if (!newToolNames.has(name)) { + this.toolRegistry.unregister(name); + removed.push(name); + } + } + + this.previousToolNames = newToolNames; + this.specHash = loader.getSpecHash(); + this.lastUpdate = new Date().toISOString(); + + const event: ToolsUpdatedEvent = { added, removed, updated }; + this.emit('toolsUpdated', event); + + // Emit catalog_changed for connected clients + if (added.length > 0 || removed.length > 0) { + this.emit('catalogChanged', { + version: this.specHash, + addedActions: added, + removedActions: removed, + }); + } + } + + /** + * Get the set of tool names managed by this source. + */ + getToolNames(): Set { + return new Set(this.previousToolNames); + } + + /** + * Get source statistics. + */ + getStats(): OpenApiSourceStats { + return { + toolCount: this.previousToolNames.size, + lastUpdate: this.lastUpdate, + specHash: this.specHash, + pollHealth: this.health, + }; + } + + /** + * Get the source name. + */ + getName(): string { + return this.config.name; + } + + /** + * Dispose the source and release resources. + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.stop(); + if (this.poller) { + this.poller.dispose(); + this.poller = null; + } + this.removeAllListeners(); + } +} diff --git a/libs/broker/src/openapi/openapi-spec-poller.ts b/libs/broker/src/openapi/openapi-spec-poller.ts new file mode 100644 index 0000000..c1ee3f8 --- /dev/null +++ b/libs/broker/src/openapi/openapi-spec-poller.ts @@ -0,0 +1,304 @@ +/** + * OpenAPI Spec Poller + * + * Polls an OpenAPI specification URL for changes using ETag/content-hash detection. + * Follows the HealthChecker pattern (EventEmitter, start/stop, setInterval). + * + * @packageDocumentation + */ + +import { EventEmitter } from 'events'; +import { sha256Hex } from './hash-utils'; + +/** + * Change detection strategy. + */ +export type ChangeDetectionMode = 'content-hash' | 'etag' | 'auto'; + +/** + * Retry configuration for failed polls. + */ +export interface PollerRetryConfig { + /** Maximum number of retries per poll cycle @default 3 */ + maxRetries?: number; + /** Initial delay before first retry in ms @default 1000 */ + initialDelayMs?: number; + /** Maximum delay between retries in ms @default 10000 */ + maxDelayMs?: number; + /** Backoff multiplier @default 2 */ + backoffMultiplier?: number; +} + +/** + * Configuration for the OpenAPI spec poller. + */ +export interface OpenApiPollerConfig { + /** URL of the OpenAPI specification */ + url: string; + /** Poll interval in milliseconds @default 60000 */ + intervalMs?: number; + /** Fetch timeout in milliseconds @default 10000 */ + fetchTimeoutMs?: number; + /** Change detection strategy @default 'auto' */ + changeDetection?: ChangeDetectionMode; + /** Retry configuration */ + retry?: PollerRetryConfig; + /** Number of consecutive failures before marking unhealthy @default 3 */ + unhealthyThreshold?: number; + /** Additional headers for fetch requests */ + headers?: Record; +} + +/** + * Events emitted by the poller. + */ +export interface OpenApiPollerEvents { + changed: [spec: string, hash: string]; + unchanged: []; + pollError: [error: Error]; + unhealthy: [consecutiveFailures: number]; + recovered: []; +} + +const DEFAULT_RETRY: Required = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 10000, + backoffMultiplier: 2, +}; + +/** + * Polls an OpenAPI spec URL for changes using ETag and content-hash detection. + */ +export class OpenApiSpecPoller extends EventEmitter { + private readonly config: Required> & { + retry: Required; + headers: Record; + }; + + private intervalTimer: ReturnType | null = null; + private lastHash: string | null = null; + private lastEtag: string | null = null; + private lastModified: string | null = null; + private consecutiveFailures = 0; + private isPolling = false; + private wasUnhealthy = false; + /** Per-run abort controller — aborted on stop(), recreated on start() */ + private _runAbort: AbortController = new AbortController(); + private _fetchController: AbortController | null = null; + private _activePoll: Promise | null = null; + + constructor(config: OpenApiPollerConfig) { + super(); + this.config = { + url: config.url, + intervalMs: config.intervalMs ?? 60000, + fetchTimeoutMs: config.fetchTimeoutMs ?? 10000, + changeDetection: config.changeDetection ?? 'auto', + unhealthyThreshold: config.unhealthyThreshold ?? 3, + retry: { ...DEFAULT_RETRY, ...config.retry }, + headers: config.headers ?? {}, + }; + } + + /** + * Start polling for changes. + */ + start(): void { + if (this.intervalTimer) return; + this._runAbort = new AbortController(); + const runSignal = this._runAbort.signal; + + // If a previous poll is still settling after stop(), chain the initial + // poll so it runs once the old one completes instead of being dropped. + const pendingPoll = this._activePoll; + if (pendingPoll) { + pendingPoll.finally(() => { + if (!runSignal.aborted) { + this._activePoll = this.pollWithSignal(runSignal).catch(() => undefined); + } + }); + } else { + this._activePoll = this.pollWithSignal(runSignal).catch(() => undefined); + } + + this.intervalTimer = setInterval(() => { + this._activePoll = this.pollWithSignal(runSignal).catch(() => undefined); + }, this.config.intervalMs); + } + + /** + * Stop polling. + */ + stop(): void { + this._runAbort.abort(); + if (this._fetchController) { + this._fetchController.abort(); + this._fetchController = null; + } + if (this.intervalTimer) { + clearInterval(this.intervalTimer); + this.intervalTimer = null; + } + } + + /** + * Get the current content hash. + */ + getHash(): string | null { + return this.lastHash; + } + + /** + * Get the number of consecutive failures. + */ + getConsecutiveFailures(): number { + return this.consecutiveFailures; + } + + /** + * Perform a single poll cycle. + */ + async poll(): Promise { + return this.pollWithSignal(this._runAbort.signal); + } + + private async pollWithSignal(runSignal: AbortSignal): Promise { + if (this.isPolling) return; + this.isPolling = true; + + try { + await this.fetchWithRetry(runSignal); + } finally { + this.isPolling = false; + this._activePoll = null; + } + } + + private async fetchWithRetry(runSignal: AbortSignal): Promise { + const { maxRetries, initialDelayMs, maxDelayMs, backoffMultiplier } = this.config.retry; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (runSignal.aborted) return; + + try { + await this.doFetch(runSignal); + if (runSignal.aborted) return; + // Success — reset failure counter and emit recovered if applicable + if (this.consecutiveFailures > 0) { + this.consecutiveFailures = 0; + if (this.wasUnhealthy) { + this.wasUnhealthy = false; + this.emit('recovered'); + } + } + return; + } catch (error) { + if (runSignal.aborted) return; + + if (attempt === maxRetries) { + this.consecutiveFailures++; + this.emit('pollError', error instanceof Error ? error : new Error(String(error))); + + if (this.consecutiveFailures >= this.config.unhealthyThreshold && !this.wasUnhealthy) { + this.wasUnhealthy = true; + this.emit('unhealthy', this.consecutiveFailures); + } + return; + } + + // Wait before retrying (abortable) + const delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, attempt), maxDelayMs); + await new Promise((resolve) => { + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + runSignal.removeEventListener('abort', onAbort); + resolve(); + }, delay); + runSignal.addEventListener('abort', onAbort, { once: true }); + }); + } + } + } + + private async doFetch(runSignal: AbortSignal): Promise { + if (runSignal.aborted) return; + + const headers: Record = { ...this.config.headers }; + + // Add conditional request headers + if (this.config.changeDetection !== 'content-hash') { + if (this.lastEtag) { + headers['If-None-Match'] = this.lastEtag; + } + if (this.lastModified) { + headers['If-Modified-Since'] = this.lastModified; + } + } + + const controller = new AbortController(); + this._fetchController = controller; + const timeout = setTimeout(() => controller.abort(), this.config.fetchTimeoutMs); + + try { + const response = await fetch(this.config.url, { + headers, + signal: controller.signal, + }); + + if (runSignal.aborted) return; + + // HTTP 304: Not Modified + if (response.status === 304) { + this.emit('unchanged'); + return; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Read response headers (defer storing until body is fully processed) + const etag = response.headers.get('etag'); + const lastModified = response.headers.get('last-modified'); + + const body = await response.text(); + + if (runSignal.aborted) return; + + const hash = await sha256Hex(body); + + if (runSignal.aborted) return; + + if (this.lastHash && this.lastHash === hash) { + this.emit('unchanged'); + if (etag) this.lastEtag = etag; + if (lastModified) this.lastModified = lastModified; + return; + } + + this.emit('changed', body, hash); + this.lastHash = hash; + + // Store headers only after successful body processing + if (etag) this.lastEtag = etag; + if (lastModified) this.lastModified = lastModified; + } finally { + clearTimeout(timeout); + if (this._fetchController === controller) { + this._fetchController = null; + } + } + } + + /** + * Dispose the poller and release resources. + */ + dispose(): void { + this.stop(); + this.removeAllListeners(); + } +} diff --git a/libs/broker/src/openapi/openapi-tool-loader.ts b/libs/broker/src/openapi/openapi-tool-loader.ts new file mode 100644 index 0000000..2fbe43c --- /dev/null +++ b/libs/broker/src/openapi/openapi-tool-loader.ts @@ -0,0 +1,524 @@ +/** + * OpenAPI Tool Loader + * + * Converts OpenAPI specifications into Enclave ToolDefinitions. + * Uses mcp-from-openapi as an optional peer dependency. + * + * @packageDocumentation + */ + +import { z } from 'zod'; +import { sha256Hex } from './hash-utils'; +import type { ToolDefinition, ToolContext } from '../tool-registry'; + +/** + * Options for the OpenAPI tool loader. + */ +export interface LoaderOptions { + /** Base URL for the upstream API */ + baseUrl?: string; + /** Additional headers for API requests */ + headers?: Record; + /** Custom naming strategy for tool names */ + namingStrategy?: (method: string, path: string, operationId?: string) => string; + /** Only include these operation IDs */ + includeOperations?: string[]; + /** Exclude these operation IDs */ + excludeOperations?: string[]; + /** Default timeout per API call in ms @default 30000 */ + perToolDeadlineMs?: number; + /** Source/service name prefix for default tool names (prevents collisions across sources) */ + sourceName?: string; +} + +/** + * Authentication configuration for upstream API requests. + */ +export interface UpstreamAuth { + type: 'bearer' | 'api-key' | 'basic'; + token?: string; + header?: string; +} + +/** + * Represents a parsed OpenAPI operation. + */ +interface ParsedOperation { + operationId: string; + method: string; + path: string; + summary?: string; + description?: string; + parameters?: Array<{ + name: string; + in: 'path' | 'query' | 'header'; + required?: boolean; + schema?: Record; + }>; + requestBody?: { + required?: boolean; + content?: Record }>; + }; + deprecated?: boolean; +} + +/** + * Converts OpenAPI specs into Enclave ToolDefinitions. + */ +export class OpenApiToolLoader { + private tools: ToolDefinition[] = []; + private toolNames: Set = new Set(); + private specHash: string; + private readonly options: LoaderOptions; + private readonly auth?: UpstreamAuth; + + private constructor(spec: Record, hash: string, options: LoaderOptions = {}, auth?: UpstreamAuth) { + this.options = options; + this.auth = auth; + this.specHash = hash; + this.loadFromSpec(spec); + } + + /** + * Create a loader from a URL. + */ + static async fromURL(url: string, options?: LoaderOptions, auth?: UpstreamAuth): Promise { + const headers: Record = { ...options?.headers }; + if (auth) { + switch (auth.type) { + case 'bearer': + if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; + break; + case 'api-key': + if (auth.token) headers[auth.header ?? 'X-API-Key'] = auth.token; + break; + case 'basic': + if (auth.token) headers['Authorization'] = `Basic ${auth.token}`; + break; + } + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`Failed to fetch OpenAPI spec from ${url}: ${response.status}`); + } + const spec = (await response.json()) as Record; + const hash = await sha256Hex(JSON.stringify(spec)); + + // Resolve baseUrl: explicit option > spec.servers[0].url > fetch origin + let resolvedBaseUrl = options?.baseUrl; + if (!resolvedBaseUrl) { + const servers = spec['servers'] as Array<{ url?: string }> | undefined; + if (servers?.[0]?.url) { + try { + resolvedBaseUrl = new URL(servers[0].url, url).origin + new URL(servers[0].url, url).pathname; + } catch { + resolvedBaseUrl = new URL(url).origin; + } + } else { + resolvedBaseUrl = new URL(url).origin; + } + } + + return new OpenApiToolLoader(spec, hash, { ...options, baseUrl: resolvedBaseUrl }, auth); + } + + /** + * Create a loader from a spec object. + */ + static async fromSpec( + spec: Record, + options?: LoaderOptions, + auth?: UpstreamAuth, + ): Promise { + const hash = await sha256Hex(JSON.stringify(spec)); + + // Resolve baseUrl from spec.servers when not explicitly provided + let resolvedBaseUrl = options?.baseUrl; + if (!resolvedBaseUrl) { + const servers = spec['servers'] as Array<{ url?: string }> | undefined; + if (servers?.[0]?.url) { + try { + const serverUrl = new URL(servers[0].url); + resolvedBaseUrl = serverUrl.origin + serverUrl.pathname; + } catch { + throw new Error( + `Cannot resolve relative server URL "${servers[0].url}" without a source URL. Use fromURL() or provide an explicit baseUrl option.`, + ); + } + } + } + + return new OpenApiToolLoader( + spec, + hash, + resolvedBaseUrl ? { ...options, baseUrl: resolvedBaseUrl } : options, + auth, + ); + } + + /** + * Get all loaded tool definitions. + */ + getTools(): ToolDefinition[] { + return [...this.tools]; + } + + /** + * Get all tool names. + */ + getToolNames(): Set { + return new Set(this.toolNames); + } + + /** + * Get the hash of the loaded spec. + */ + getSpecHash(): string { + return this.specHash; + } + + /** + * Parse the OpenAPI spec and generate tool definitions. + */ + private loadFromSpec(spec: Record): void { + const paths = spec['paths'] as Record> | undefined; + if (!paths) return; + + const baseUrl = this.options.baseUrl ?? ''; + const operations = this.extractOperations(paths); + + for (const op of operations) { + // Apply filters + if (this.options.includeOperations && !this.options.includeOperations.includes(op.operationId)) { + continue; + } + if (this.options.excludeOperations && this.options.excludeOperations.includes(op.operationId)) { + continue; + } + + const baseName = op.operationId || `${op.method}_${op.path.replace(/[^a-zA-Z0-9]/g, '_')}`; + const toolName = this.options.namingStrategy + ? this.options.namingStrategy(op.method, op.path, op.operationId) + : this.options.sourceName + ? `${this.options.sourceName}_${baseName}` + : baseName; + + const { schema: argsSchema, bodyMediaType } = this.buildArgsSchema(op); + const timeout = this.options.perToolDeadlineMs ?? 30000; + + const tool: ToolDefinition = { + name: toolName, + description: op.summary || op.description || `${op.method.toUpperCase()} ${op.path}`, + argsSchema, + config: { + timeout, + }, + handler: this.createHandler(op, baseUrl, bodyMediaType), + }; + + if (this.toolNames.has(toolName)) { + throw new Error( + `Duplicate tool name "${toolName}" generated for ${op.method.toUpperCase()} ${op.path}` + + ` (operationId: ${op.operationId}). Use a custom namingStrategy or sourceName to disambiguate.`, + ); + } + + this.tools.push(tool); + this.toolNames.add(toolName); + } + } + + /** + * Extract operations from OpenAPI paths. + */ + private extractOperations(paths: Record>): ParsedOperation[] { + const operations: ParsedOperation[] = []; + const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options']; + + for (const [path, pathItem] of Object.entries(paths)) { + const pathParams = (pathItem['parameters'] as ParsedOperation['parameters']) ?? []; + + for (const method of httpMethods) { + const operation = pathItem[method] as Record | undefined; + if (!operation) continue; + + const operationId = (operation['operationId'] as string) || `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`; + + // Merge path-level and operation-level parameters; operation-level overrides by (name, in) + const opParams = (operation['parameters'] as ParsedOperation['parameters']) ?? []; + const merged = this.mergeParameters(pathParams, opParams); + + operations.push({ + operationId, + method, + path, + summary: operation['summary'] as string | undefined, + description: operation['description'] as string | undefined, + parameters: merged.length > 0 ? merged : undefined, + requestBody: operation['requestBody'] as ParsedOperation['requestBody'], + deprecated: operation['deprecated'] as boolean | undefined, + }); + } + } + + return operations; + } + + /** + * Merge path-level and operation-level parameters, de-duplicating by (name, in). + * Operation-level entries override path-level ones. + */ + private mergeParameters( + pathParams: NonNullable, + opParams: NonNullable, + ): NonNullable { + const seen = new Map(); + for (const p of pathParams) { + seen.set(`${p.in}:${p.name}`, p); + } + for (const p of opParams) { + seen.set(`${p.in}:${p.name}`, p); + } + return [...seen.values()]; + } + + /** + * Build a Zod args schema from an OpenAPI operation. + */ + private buildArgsSchema(op: ParsedOperation): { schema: z.ZodType; bodyMediaType?: string } { + const shape: Record = {}; + let bodyMediaType: string | undefined; + + // Add parameters with OpenAPI type mapping + if (op.parameters) { + for (const param of op.parameters) { + const baseType = this.mapOpenApiType(param.schema); + shape[param.name] = param.required ? baseType : baseType.optional(); + } + } + + // Add request body as 'body' parameter, inspecting content media type + if (op.requestBody?.content) { + const { schema: bodySchema, mediaType } = this.buildBodySchema(op.requestBody.content); + shape['body'] = op.requestBody.required ? bodySchema : bodySchema.optional(); + bodyMediaType = mediaType; + } else if (op.requestBody) { + const fallback = z.record(z.string(), z.unknown()); + shape['body'] = op.requestBody.required ? fallback : fallback.optional(); + } + + const schema = Object.keys(shape).length > 0 ? z.object(shape) : z.record(z.string(), z.unknown()); + return { schema, bodyMediaType }; + } + + /** + * Serialize a parameter value for use in URLs/headers. + * Arrays and objects are JSON-stringified; primitives use String(). + */ + private static serializeParam(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + + /** + * Check if a content-type string represents a JSON media type. + * Matches 'application/json' and structured syntax suffix '+json' (RFC 6838). + */ + private static isJsonContentType(contentType: string): boolean { + return contentType.includes('application/json') || contentType.includes('+json'); + } + + /** + * Map an OpenAPI schema type to the corresponding Zod type. + */ + private mapOpenApiType(schema?: Record): z.ZodType { + if (!schema || !schema['type']) return z.unknown(); + + const type = schema['type'] as string; + const enumValues = schema['enum'] as string[] | undefined; + + switch (type) { + case 'integer': + return z.number().int(); + case 'number': + return z.number(); + case 'boolean': + return z.boolean(); + case 'array': + return z.array(this.mapOpenApiType(schema['items'] as Record | undefined)); + case 'object': + return z.record(z.string(), z.unknown()); + case 'string': + if (enumValues && enumValues.length > 0) { + return z.enum(enumValues as [string, ...string[]]); + } + return z.string(); + default: + return z.unknown(); + } + } + + /** + * Build a Zod schema for the request body based on the media type. + * Returns both the schema and the chosen media type for correct serialization. + */ + private buildBodySchema(content: Record }>): { + schema: z.ZodType; + mediaType: string; + } { + // Prefer JSON media types + const jsonKey = Object.keys(content).find((k) => k.includes('json')); + if (jsonKey) { + const schema = content[jsonKey].schema; + if (schema) return { schema: this.mapOpenApiType(schema), mediaType: jsonKey }; + return { schema: z.record(z.string(), z.unknown()), mediaType: jsonKey }; + } + + // Form data + if (content['application/x-www-form-urlencoded']) { + return { schema: z.record(z.string(), z.unknown()), mediaType: 'application/x-www-form-urlencoded' }; + } + if (content['multipart/form-data']) { + return { schema: z.record(z.string(), z.unknown()), mediaType: 'multipart/form-data' }; + } + + // Plain text + const textKey = Object.keys(content).find((k) => k.startsWith('text/')); + if (textKey) { + return { schema: z.string(), mediaType: textKey }; + } + + // Fallback + return { schema: z.record(z.string(), z.unknown()), mediaType: 'application/json' }; + } + + /** + * Create a handler function for an OpenAPI operation. + */ + private createHandler(op: ParsedOperation, baseUrl: string, bodyMediaType?: string): ToolDefinition['handler'] { + const auth = this.auth; + const headers = this.options.headers ?? {}; + + return async (args: unknown, context: ToolContext): Promise => { + const params = args as Record; + + // Build URL with path parameters + let normalizedBase = baseUrl; + while (normalizedBase.endsWith('/')) normalizedBase = normalizedBase.slice(0, -1); + let url = `${normalizedBase}${op.path.startsWith('/') ? op.path : '/' + op.path}`; + const queryParams: Record = {}; + + if (op.parameters) { + for (const param of op.parameters) { + const value = params[param.name]; + if (value === undefined) continue; + + if (param.in === 'path') { + url = url.replace(`{${param.name}}`, encodeURIComponent(OpenApiToolLoader.serializeParam(value))); + } else if (param.in === 'query') { + queryParams[param.name] = OpenApiToolLoader.serializeParam(value); + } + } + } + + // Append query parameters + const queryString = new URLSearchParams(queryParams).toString(); + if (queryString) { + url += `?${queryString}`; + } + + // Build request headers (only set Content-Type for methods with a body) + const hasBody = + (['post', 'put', 'patch', 'delete'].includes(op.method) || op.requestBody != null) && + Object.prototype.hasOwnProperty.call(params, 'body') && + params['body'] != null; + const resolvedMediaType = bodyMediaType ?? 'application/json'; + const isMultipart = resolvedMediaType === 'multipart/form-data'; + const requestHeaders: Record = { + // Omit Content-Type for multipart/form-data to let the runtime set the boundary + ...(hasBody && !isMultipart && { 'Content-Type': resolvedMediaType }), + ...headers, + }; + + // Add auth headers + if (auth) { + switch (auth.type) { + case 'bearer': + if (auth.token) requestHeaders['Authorization'] = `Bearer ${auth.token}`; + break; + case 'api-key': + if (auth.token) requestHeaders[auth.header ?? 'X-API-Key'] = auth.token; + break; + case 'basic': + if (auth.token) requestHeaders['Authorization'] = `Basic ${auth.token}`; + break; + } + } + + // Add header parameters (skip protected auth headers to prevent credential overwrite) + const protectedHeaders = new Set(); + if (auth) { + if (auth.type === 'bearer' || auth.type === 'basic') protectedHeaders.add('authorization'); + if (auth.type === 'api-key') protectedHeaders.add((auth.header ?? 'X-API-Key').toLowerCase()); + } + if (op.parameters) { + for (const param of op.parameters) { + if (param.in === 'header' && params[param.name] !== undefined) { + if (protectedHeaders.has(param.name.toLowerCase())) continue; + requestHeaders[param.name] = OpenApiToolLoader.serializeParam(params[param.name]); + } + } + } + + // Build fetch options + const fetchOptions: RequestInit = { + method: op.method.toUpperCase(), + headers: requestHeaders, + signal: context.signal, + }; + + if (hasBody) { + if (OpenApiToolLoader.isJsonContentType(resolvedMediaType)) { + fetchOptions.body = JSON.stringify(params['body']); + } else if (resolvedMediaType === 'application/x-www-form-urlencoded') { + fetchOptions.body = new URLSearchParams(params['body'] as Record).toString(); + } else if (resolvedMediaType === 'multipart/form-data') { + const formData = new FormData(); + const bodyObj = params['body'] as Record; + for (const [key, value] of Object.entries(bodyObj)) { + formData.append(key, value instanceof Blob ? value : String(value)); + } + fetchOptions.body = formData; + } else if (resolvedMediaType.startsWith('text/')) { + fetchOptions.body = String(params['body']); + } else { + fetchOptions.body = JSON.stringify(params['body']); + } + } + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const contentType = response.headers.get('content-type') ?? ''; + let body: string; + try { + body = OpenApiToolLoader.isJsonContentType(contentType) + ? JSON.stringify(await response.json()) + : await response.text(); + } catch { + body = ''; + } + throw new Error(`HTTP ${response.status} ${response.statusText}: ${body}`); + } + + const contentType = response.headers.get('content-type') ?? ''; + + if (OpenApiToolLoader.isJsonContentType(contentType)) { + return response.json(); + } + return response.text(); + }; + } +} diff --git a/libs/broker/src/tool-registry.ts b/libs/broker/src/tool-registry.ts index 1290704..29bd0a5 100644 --- a/libs/broker/src/tool-registry.ts +++ b/libs/broker/src/tool-registry.ts @@ -149,6 +149,20 @@ export class ToolRegistry { return this.tools.delete(name); } + /** + * Replace an existing tool with a new definition. + * If the tool doesn't exist, it registers it as new. + */ + replace(definition: ToolDefinition): void { + this.tools.delete(definition.name); + + const argsSchema = definition.argsSchema ?? z.record(z.string(), z.unknown()); + this.tools.set(definition.name, { + definition: definition as ToolDefinition, + argsSchema, + }); + } + /** * Check if a tool is registered */ diff --git a/libs/client/src/client.ts b/libs/client/src/client.ts index 7ebc07d..2826ea1 100644 --- a/libs/client/src/client.ts +++ b/libs/client/src/client.ts @@ -17,6 +17,10 @@ import { isToolResultAppliedEvent, isHeartbeatEvent, isErrorEvent, + isPartialResultEvent, + isToolProgressEvent, + isDeadlineExceededEvent, + isCatalogChangedEvent, } from '@enclave-vm/types'; import { parseNdjsonStream, ReconnectionStateMachine, HeartbeatMonitor } from '@enclave-vm/stream'; @@ -406,6 +410,23 @@ export class EnclaveClient { session.handlers.onHeartbeat?.(); } else if (isErrorEvent(event)) { session.handlers.onError?.(event.payload.code ?? 'UNKNOWN', event.payload.message); + } else if (isPartialResultEvent(event)) { + session.handlers.onPartialResult?.( + event.payload.path, + event.payload.data, + event.payload.error, + event.payload.hasNext, + ); + } else if (isToolProgressEvent(event)) { + session.handlers.onToolProgress?.(event.payload.callId, event.payload.phase, event.payload.elapsedMs); + } else if (isDeadlineExceededEvent(event)) { + session.handlers.onDeadlineExceeded?.(event.payload.elapsedMs, event.payload.budgetMs); + } else if (isCatalogChangedEvent(event)) { + session.handlers.onCatalogChanged?.( + event.payload.version, + event.payload.addedActions, + event.payload.removedActions, + ); } else if (isFinalEvent(event)) { this.handleFinalEvent(session, event); } @@ -438,6 +459,15 @@ export class EnclaveClient { }; } + // Preserve per-path errors + if (event.payload.errors?.length) { + result.errors = event.payload.errors.map((e) => ({ + code: e.code, + message: e.message, + path: e.path, + })); + } + this.completeSession(session.sessionId, result); } diff --git a/libs/client/src/types.ts b/libs/client/src/types.ts index 6df015f..0ebdd7f 100644 --- a/libs/client/src/types.ts +++ b/libs/client/src/types.ts @@ -6,7 +6,7 @@ * @packageDocumentation */ -import type { SessionId, StreamEvent, SessionLimits } from '@enclave-vm/types'; +import type { SessionId, StreamEvent, SessionLimits, ErrorPayload, ToolProgressPhase } from '@enclave-vm/types'; /** * Client configuration @@ -120,6 +120,26 @@ export interface SessionEventHandlers { * Called on non-fatal error */ onError?: (code: string, message: string) => void; + + /** + * Called when a partial result arrives + */ + onPartialResult?: (path: string[], data?: unknown, error?: ErrorPayload, hasNext?: boolean) => void; + + /** + * Called when tool progress is reported + */ + onToolProgress?: (callId: string, phase: ToolProgressPhase, elapsedMs: number) => void; + + /** + * Called when deadline is exceeded + */ + onDeadlineExceeded?: (elapsedMs: number, budgetMs: number) => void; + + /** + * Called when the action catalog changes + */ + onCatalogChanged?: (version: string, addedActions: string[], removedActions: string[]) => void; } /** @@ -149,6 +169,15 @@ export interface SessionResult { message: string; }; + /** + * Per-path errors (GraphQL-style) + */ + errors?: Array<{ + code: string; + message: string; + path?: string[]; + }>; + /** * Execution statistics */ diff --git a/libs/core/src/session/session-emitter.ts b/libs/core/src/session/session-emitter.ts index e14379b..289ea7d 100644 --- a/libs/core/src/session/session-emitter.ts +++ b/libs/core/src/session/session-emitter.ts @@ -22,6 +22,7 @@ import type { CallId, LogPayload, ErrorInfo, + ErrorPayload, } from '@enclave-vm/types'; import { PROTOCOL_VERSION, EventType, LogLevel } from '@enclave-vm/types'; import type { SessionEventEmitter } from '../session-types'; @@ -89,6 +90,13 @@ export class SessionEmitter implements SessionEventEmitter { return this.seq; } + /** + * Advance and return the next sequence number. + */ + nextSeq(): number { + return ++this.seq; + } + /** * Emit a stream event */ @@ -212,7 +220,7 @@ export class SessionEmitter implements SessionEventEmitter { /** * Create and emit a final event (success) */ - emitFinalSuccess(result: unknown, stats: SessionStats): FinalEvent { + emitFinalSuccess(result: unknown, stats: SessionStats, errors?: ErrorPayload[]): FinalEvent { const event: FinalEvent = { protocolVersion: PROTOCOL_VERSION, sessionId: this.sessionId, @@ -221,6 +229,7 @@ export class SessionEmitter implements SessionEventEmitter { payload: { ok: true, result, + ...(errors && errors.length > 0 && { errors }), stats, }, }; @@ -231,7 +240,7 @@ export class SessionEmitter implements SessionEventEmitter { /** * Create and emit a final event (failure) */ - emitFinalError(error: ErrorInfo, stats: SessionStats): FinalEvent { + emitFinalError(error: ErrorInfo, stats: SessionStats, errors?: ErrorPayload[]): FinalEvent { const event: FinalEvent = { protocolVersion: PROTOCOL_VERSION, sessionId: this.sessionId, @@ -240,6 +249,7 @@ export class SessionEmitter implements SessionEventEmitter { payload: { ok: false, error, + ...(errors && errors.length > 0 && { errors }), stats, }, }; diff --git a/libs/types/src/events.ts b/libs/types/src/events.ts index 10b3963..8fdf1e5 100644 --- a/libs/types/src/events.ts +++ b/libs/types/src/events.ts @@ -6,6 +6,34 @@ import type { ProtocolVersion, SessionId, CallId, ErrorInfo, LogLevel } from './protocol.js'; +// ============================================================================ +// Rich Error Types (inspired by gRPC google.rpc.Status) +// ============================================================================ + +/** + * Typed error details for structured error reporting. + */ +export type ErrorDetail = + | { type: 'retry_info'; retryDelayMs: number } + | { type: 'upstream_info'; statusCode: number; url: string } + | { type: 'validation_info'; field: string; reason: string } + | { type: 'quota_info'; limit: number; used: number }; + +/** + * Rich error payload following gRPC error model. + * Supports per-path errors with typed details. + */ +export interface ErrorPayload { + /** Machine-readable error code (e.g., 'TOOL_TIMEOUT', 'VALIDATION_ERROR') */ + code: string; + /** Human-readable error message */ + message: string; + /** Path to the failing operation (like GraphQL path) */ + path?: string[]; + /** Typed error details for programmatic handling */ + details?: ErrorDetail[]; +} + /** * Base event structure shared by all stream events. */ @@ -40,6 +68,14 @@ export const EventType = { Error: 'error', /** Encrypted envelope (wraps other events) */ Encrypted: 'enc', + /** Partial result (GraphQL-inspired, data+errors coexist) */ + PartialResult: 'partial_result', + /** Tool execution progress */ + ToolProgress: 'tool_progress', + /** Deadline exceeded for execution */ + DeadlineExceeded: 'deadline_exceeded', + /** Action catalog changed (for long-lived connections) */ + CatalogChanged: 'catalog_changed', } as const; export type EventType = (typeof EventType)[keyof typeof EventType]; @@ -179,14 +215,17 @@ export interface ToolResultAppliedEvent extends BaseEvent { /** * Final event payload. + * Supports mixed results (GraphQL-inspired): data and errors can coexist. */ export interface FinalPayload { - /** Whether execution completed successfully */ + /** Whether all operations completed successfully */ ok: boolean; - /** Execution result (if ok is true) */ + /** Execution result (full aggregated result) */ result?: unknown; - /** Error information (if ok is false) */ + /** Error information (if ok is false, legacy single error) */ error?: ErrorInfo; + /** Per-path errors (GraphQL-style errors array) */ + errors?: ErrorPayload[]; /** Execution statistics */ stats?: SessionStats; } @@ -258,6 +297,118 @@ export interface ErrorEvent extends BaseEvent { payload: ErrorEventPayload; } +// ============================================================================ +// Partial Result Event (GraphQL-inspired) +// ============================================================================ + +/** + * Partial result event payload. + * When code fans out to multiple APIs, results arrive incrementally. + */ +export interface PartialResultPayload { + /** Path to where this result slots in (like GraphQL path) */ + path: string[]; + /** The partial data (if this path succeeded) */ + data?: unknown; + /** The error for this path (if this path failed) */ + error?: ErrorPayload; + /** Whether more partial results are coming */ + hasNext: boolean; +} + +/** + * Partial result event. + * Emitted when individual parts of a fan-out execution complete. + */ +export interface PartialResultEvent extends BaseEvent { + type: typeof EventType.PartialResult; + payload: PartialResultPayload; +} + +// ============================================================================ +// Tool Progress Event (gRPC server streaming inspired) +// ============================================================================ + +/** + * Tool progress phase. + */ +export type ToolProgressPhase = 'connecting' | 'sending' | 'receiving' | 'processing'; + +/** + * Tool progress event payload. + * Reports progress for long-running tool calls. + */ +export interface ToolProgressPayload { + /** Call ID of the tool call */ + callId: CallId; + /** Current phase of execution */ + phase: ToolProgressPhase; + /** Bytes received so far */ + bytesReceived?: number; + /** Total bytes expected (if Content-Length known) */ + totalBytes?: number; + /** Elapsed time in milliseconds */ + elapsedMs: number; +} + +/** + * Tool progress event. + * Emitted during long-running tool calls to report progress. + */ +export interface ToolProgressEvent extends BaseEvent { + type: typeof EventType.ToolProgress; + payload: ToolProgressPayload; +} + +// ============================================================================ +// Deadline Exceeded Event (gRPC-inspired) +// ============================================================================ + +/** + * Deadline exceeded event payload. + */ +export interface DeadlineExceededPayload { + /** Total elapsed time in milliseconds */ + elapsedMs: number; + /** The deadline budget that was exceeded in milliseconds */ + budgetMs: number; +} + +/** + * Deadline exceeded event. + * Emitted when execution exceeds the configured deadline. + */ +export interface DeadlineExceededEvent extends BaseEvent { + type: typeof EventType.DeadlineExceeded; + payload: DeadlineExceededPayload; +} + +// ============================================================================ +// Catalog Changed Event +// ============================================================================ + +/** + * Catalog changed event payload. + * Notifies clients that the available action catalog has changed. + */ +export interface CatalogChangedPayload { + /** New catalog version hash */ + version: string; + /** Names of newly added actions */ + addedActions: string[]; + /** Names of removed actions */ + removedActions: string[]; +} + +/** + * Catalog changed event. + * Emitted when the action catalog is updated (tools added/removed via OpenAPI polling). + */ +export interface CatalogChangedEvent extends BaseEvent { + type: typeof EventType.CatalogChanged; + payload: CatalogChangedPayload; +} + // ============================================================================ // Stream Event Union // ============================================================================ @@ -273,7 +424,11 @@ export type StreamEvent = | ToolResultAppliedEvent | FinalEvent | HeartbeatEvent - | ErrorEvent; + | ErrorEvent + | PartialResultEvent + | ToolProgressEvent + | DeadlineExceededEvent + | CatalogChangedEvent; /** * Get the event type from a stream event. @@ -338,6 +493,34 @@ export function isErrorEvent(event: StreamEvent): event is ErrorEvent { return event.type === EventType.Error; } +/** + * Type guard for partial result event. + */ +export function isPartialResultEvent(event: StreamEvent): event is PartialResultEvent { + return event.type === EventType.PartialResult; +} + +/** + * Type guard for tool progress event. + */ +export function isToolProgressEvent(event: StreamEvent): event is ToolProgressEvent { + return event.type === EventType.ToolProgress; +} + +/** + * Type guard for deadline exceeded event. + */ +export function isDeadlineExceededEvent(event: StreamEvent): event is DeadlineExceededEvent { + return event.type === EventType.DeadlineExceeded; +} + +/** + * Type guard for catalog changed event. + */ +export function isCatalogChangedEvent(event: StreamEvent): event is CatalogChangedEvent { + return event.type === EventType.CatalogChanged; +} + // ============================================================================ // Runtime Channel Messages (Middleware <-> Runtime) // ============================================================================ diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 7f813a5..8733eeb 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -9,6 +9,7 @@ // Protocol exports export { PROTOCOL_VERSION, + PROTOCOL_VERSION_V1, SESSION_ID_PREFIX, CALL_ID_PREFIX, REF_ID_PREFIX, @@ -49,6 +50,10 @@ export { isFinalEvent, isHeartbeatEvent, isErrorEvent, + isPartialResultEvent, + isToolProgressEvent, + isDeadlineExceededEvent, + isCatalogChangedEvent, } from './events.js'; export type { @@ -77,6 +82,17 @@ export type { CancelPayload, CancelMessage, RuntimeChannelMessage, + ErrorPayload, + ErrorDetail, + PartialResultPayload, + PartialResultEvent, + ToolProgressPhase, + ToolProgressPayload, + ToolProgressEvent, + DeadlineExceededPayload, + DeadlineExceededEvent, + CatalogChangedPayload, + CatalogChangedEvent, } from './events.js'; // Encryption exports @@ -138,6 +154,18 @@ export { FinalEventSchema, HeartbeatEventSchema, ErrorEventSchema, + // New v2 event schemas + ErrorDetailSchema, + ErrorPayloadSchema, + PartialResultPayloadSchema, + PartialResultEventSchema, + ToolProgressPhaseSchema, + ToolProgressPayloadSchema, + ToolProgressEventSchema, + DeadlineExceededPayloadSchema, + DeadlineExceededEventSchema, + CatalogChangedPayloadSchema, + CatalogChangedEventSchema, StreamEventSchema, // Encryption schemas SupportedCurveSchema, diff --git a/libs/types/src/protocol.spec.ts b/libs/types/src/protocol.spec.ts index a57ccb5..970552f 100644 --- a/libs/types/src/protocol.spec.ts +++ b/libs/types/src/protocol.spec.ts @@ -18,7 +18,7 @@ import { describe('Protocol', () => { describe('Constants', () => { it('should have correct protocol version', () => { - expect(PROTOCOL_VERSION).toBe(1); + expect(PROTOCOL_VERSION).toBe(2); }); it('should have correct ID prefixes', () => { diff --git a/libs/types/src/protocol.ts b/libs/types/src/protocol.ts index 0c9ee08..9e0d35a 100644 --- a/libs/types/src/protocol.ts +++ b/libs/types/src/protocol.ts @@ -6,14 +6,20 @@ /** * Current protocol version. - * Increment when making breaking changes to the wire format. + * v2: Added partial_result, tool_progress, deadline_exceeded, catalog_changed events, + * ErrorPayload/ErrorDetail types, deadline/cancellation session config. */ -export const PROTOCOL_VERSION = 1 as const; +export const PROTOCOL_VERSION = 2 as const; + +/** + * Previous protocol version for backward compatibility. + */ +export const PROTOCOL_VERSION_V1 = 1 as const; /** * Protocol version type for type-safe versioning. */ -export type ProtocolVersion = typeof PROTOCOL_VERSION; +export type ProtocolVersion = typeof PROTOCOL_VERSION | typeof PROTOCOL_VERSION_V1; /** * Session ID prefix for identification and debugging. @@ -126,6 +132,27 @@ export interface SessionLimits { * @default 15000 (15 seconds) */ heartbeatIntervalMs?: number; + + /** + * Absolute deadline for entire execution in milliseconds. + * When exceeded, a deadline_exceeded event is emitted and execution is cancelled. + * @default undefined (no deadline) + */ + deadlineMs?: number; + + /** + * Default per-tool timeout in milliseconds (overridable per tool via ToolConfig). + * Inherits the remaining execution budget when deadlineMs is set. + * @default 30000 (30 seconds) + */ + perToolDeadlineMs?: number; + + /** + * Whether to abort remaining tool calls on first failure. + * When true, the session cancels all pending tool calls on the first error. + * @default false + */ + cancelOnFirstError?: boolean; } /** @@ -138,6 +165,9 @@ export const DEFAULT_SESSION_LIMITS: Required = { maxToolResultBytes: 5242880, toolTimeoutMs: 30000, heartbeatIntervalMs: 15000, + deadlineMs: 0, + perToolDeadlineMs: 30000, + cancelOnFirstError: false, }; /** diff --git a/libs/types/src/schemas.spec.ts b/libs/types/src/schemas.spec.ts index bb163d4..ddba0ba 100644 --- a/libs/types/src/schemas.spec.ts +++ b/libs/types/src/schemas.spec.ts @@ -172,7 +172,7 @@ describe('Schemas', () => { it('should reject invalid session creation requests', () => { expect(parseCreateSessionRequest({ code: '' }).success).toBe(false); - expect(parseCreateSessionRequest({ protocolVersion: 2, code: 'test' }).success).toBe(false); + expect(parseCreateSessionRequest({ protocolVersion: 99, code: 'test' }).success).toBe(false); }); }); }); diff --git a/libs/types/src/schemas.ts b/libs/types/src/schemas.ts index 2a18105..d17d99f 100644 --- a/libs/types/src/schemas.ts +++ b/libs/types/src/schemas.ts @@ -22,9 +22,9 @@ import { SupportedCurve, EncryptionAlgorithm, KeyDerivation, EncryptionMode } fr // ============================================================================ /** - * Protocol version schema. + * Protocol version schema (accepts v1 and v2). */ -export const ProtocolVersionSchema = z.literal(PROTOCOL_VERSION); +export const ProtocolVersionSchema = z.union([z.literal(PROTOCOL_VERSION), z.literal(1)]); /** * Session ID schema. @@ -95,6 +95,9 @@ export const SessionLimitsSchema = z.object({ maxToolResultBytes: z.number().int().positive().optional(), toolTimeoutMs: z.number().int().positive().optional(), heartbeatIntervalMs: z.number().int().positive().optional(), + deadlineMs: z.number().int().nonnegative().optional(), + perToolDeadlineMs: z.number().int().positive().optional(), + cancelOnFirstError: z.boolean().optional(), }); /** @@ -183,6 +186,26 @@ export const SessionStatsSchema = z.object({ stdoutBytes: z.number().int().nonnegative(), }); +/** + * Error detail schema. + */ +export const ErrorDetailSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('retry_info'), retryDelayMs: z.number().nonnegative() }), + z.object({ type: z.literal('upstream_info'), statusCode: z.number().int(), url: z.string() }), + z.object({ type: z.literal('validation_info'), field: z.string(), reason: z.string() }), + z.object({ type: z.literal('quota_info'), limit: z.number().nonnegative(), used: z.number().nonnegative() }), +]); + +/** + * Error payload schema (rich error model). + */ +export const ErrorPayloadSchema = z.object({ + code: z.string(), + message: z.string(), + path: z.array(z.string()).optional(), + details: z.array(ErrorDetailSchema).optional(), +}); + /** * Final payload schema. */ @@ -190,6 +213,7 @@ export const FinalPayloadSchema = z.object({ ok: z.boolean(), result: z.unknown().optional(), error: ErrorInfoSchema.optional(), + errors: z.array(ErrorPayloadSchema).optional(), stats: SessionStatsSchema.optional(), }); @@ -277,6 +301,81 @@ export const ErrorEventSchema = BaseEventSchema.extend({ payload: ErrorEventPayloadSchema, }); +/** + * Partial result payload schema. + */ +export const PartialResultPayloadSchema = z.object({ + path: z.array(z.string()), + data: z.unknown().optional(), + error: ErrorPayloadSchema.optional(), + hasNext: z.boolean(), +}); + +/** + * Partial result event schema. + */ +export const PartialResultEventSchema = BaseEventSchema.extend({ + type: z.literal(EventType.PartialResult), + payload: PartialResultPayloadSchema, +}); + +/** + * Tool progress phase schema. + */ +export const ToolProgressPhaseSchema = z.enum(['connecting', 'sending', 'receiving', 'processing']); + +/** + * Tool progress payload schema. + */ +export const ToolProgressPayloadSchema = z.object({ + callId: CallIdSchema, + phase: ToolProgressPhaseSchema, + bytesReceived: z.number().int().nonnegative().optional(), + totalBytes: z.number().int().nonnegative().optional(), + elapsedMs: z.number().nonnegative(), +}); + +/** + * Tool progress event schema. + */ +export const ToolProgressEventSchema = BaseEventSchema.extend({ + type: z.literal(EventType.ToolProgress), + payload: ToolProgressPayloadSchema, +}); + +/** + * Deadline exceeded payload schema. + */ +export const DeadlineExceededPayloadSchema = z.object({ + elapsedMs: z.number().nonnegative(), + budgetMs: z.number().positive(), +}); + +/** + * Deadline exceeded event schema. + */ +export const DeadlineExceededEventSchema = BaseEventSchema.extend({ + type: z.literal(EventType.DeadlineExceeded), + payload: DeadlineExceededPayloadSchema, +}); + +/** + * Catalog changed payload schema. + */ +export const CatalogChangedPayloadSchema = z.object({ + version: z.string().min(1), + addedActions: z.array(z.string()), + removedActions: z.array(z.string()), +}); + +/** + * Catalog changed event schema. + */ +export const CatalogChangedEventSchema = BaseEventSchema.extend({ + type: z.literal(EventType.CatalogChanged), + payload: CatalogChangedPayloadSchema, +}); + /** * Stream event union schema. */ @@ -289,6 +388,10 @@ export const StreamEventSchema = z.discriminatedUnion('type', [ FinalEventSchema, HeartbeatEventSchema, ErrorEventSchema, + PartialResultEventSchema, + ToolProgressEventSchema, + DeadlineExceededEventSchema, + CatalogChangedEventSchema, ]); // ============================================================================ diff --git a/package.json b/package.json index 6a4ecbd..b1608bd 100644 --- a/package.json +++ b/package.json @@ -80,10 +80,14 @@ }, "resolutions": { "qs": "^6.14.2", - "node-forge": "^1.3.2", + "node-forge": "^1.4.0", "glob": "^10.5.0", "jws": "^3.2.3", "mdast-util-to-hast": "^13.2.1", - "js-yaml": "^4.1.1" + "js-yaml": "^4.1.1", + "handlebars": "^4.7.9", + "flatted": "^3.4.2", + "serialize-javascript": "^7.0.0", + "immutable": "^5.1.5" } } diff --git a/yarn.lock b/yarn.lock index ed894f6..d6c493a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1122,6 +1122,62 @@ dependencies: tslib "^2.4.0" +"@enclave-vm/ast@2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@enclave-vm/ast/-/ast-2.12.0.tgz#8b095246337f8b7e6810f72a56f62e04e14995e5" + integrity sha512-SZo4eFPzFwhYlTOQy0hSOsf7++VSi9XtPBaR/1ljA9HOAoTU2kzBaptExKpfKbQmqbZ69/cmdGrwSCS3lDkSiQ== + dependencies: + "@types/estree" "1.0.8" + acorn "8.15.0" + acorn-walk "8.3.4" + astring "1.9.0" + +"@enclave-vm/broker@2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@enclave-vm/broker/-/broker-2.12.0.tgz#0f7c56d4534da310899cde29bea4245d937ed355" + integrity sha512-Su8bcSVJIEg0I5iGiHPZwuIrnG1qPfrGSuwJuqPxAqa9GMCfPgQHk0K+c+W9jb2dzSqYeT8lCKWvdKCzDEMFqg== + dependencies: + "@enclave-vm/core" "2.12.0" + "@enclave-vm/stream" "2.12.0" + "@enclave-vm/types" "2.12.0" + minimatch "^10.1.1" + zod "^4.3.6" + +"@enclave-vm/client@2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@enclave-vm/client/-/client-2.12.0.tgz#d38fd7991fb011f47c740e2737232378a01bdc38" + integrity sha512-kNjcsj+OrD9SaXAJghwEv5c4E4JmswrnZLSqsrFFvvyKS1ZyDtFBKNKlzJ+Rs7ygIX8V6c+fG7PsRHDlPUV1aA== + dependencies: + "@enclave-vm/stream" "2.12.0" + "@enclave-vm/types" "2.12.0" + +"@enclave-vm/core@2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@enclave-vm/core/-/core-2.12.0.tgz#7726a5d6f717330633cb7c2d5c77c575cf320f75" + integrity sha512-v7uciTJWrjtO+ZuUDHy4Osu6XVPYdGvBTOisqRSn2Px3ZVHcOzelCKBOgMH8jwKKhz00/ANzJ6P2SuatfLbuKQ== + dependencies: + "@babel/standalone" "^7.29.0" + "@enclave-vm/ast" "2.12.0" + "@enclave-vm/types" "2.12.0" + acorn "8.15.0" + acorn-walk "8.3.4" + astring "1.9.0" + zod "^4.3.6" + +"@enclave-vm/stream@2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@enclave-vm/stream/-/stream-2.12.0.tgz#56b2427b11d151164d256a7bf9e1e9b5171ba92b" + integrity sha512-bwjRFsOLQAl5dUJcuzHjmD4Q24nL1BRP4GxdtJqSoOw9pnWD/EDgcybnHtOoNbaG5AYTiXyIv1mEODqE3zlOhg== + dependencies: + "@enclave-vm/types" "2.12.0" + +"@enclave-vm/types@2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@enclave-vm/types/-/types-2.12.0.tgz#571b0851ad7c6ac1b661bcea8a923c0fa63c4781" + integrity sha512-/AQ78OQKmjt3OtvXe0iFkZtwV8TzP93VOo+vU4qfYYxj6e4XgVUgWhmUtpBWzq3xaL4UHKAhcBC7CAdBGHKiNw== + dependencies: + zod "^4.3.6" + "@esbuild/aix-ppc64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" @@ -4090,6 +4146,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -4184,6 +4245,13 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" @@ -5609,10 +5677,10 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== -flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== +flatted@^3.2.9, flatted@^3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== follow-redirects@^1.0.0, follow-redirects@^1.15.6: version "1.15.11" @@ -5848,10 +5916,10 @@ handle-thing@^2.0.0: resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== -handlebars@^4.7.8: - version "4.7.8" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" - integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== +handlebars@^4.7.8, handlebars@^4.7.9: + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== dependencies: minimist "^1.2.5" neo-async "^2.6.2" @@ -6040,10 +6108,10 @@ image-size@~0.5.0: resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ== -immutable@^5.0.2: - version "5.1.4" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.4.tgz#e3f8c1fe7b567d56cf26698f31918c241dae8c1f" - integrity sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA== +immutable@^5.0.2, immutable@^5.1.5: + version "5.1.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165" + integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== import-fresh@^3.2.1: version "3.3.1" @@ -7258,6 +7326,13 @@ minimatch@3.1.2, minimatch@^3.0.4, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^10.2.5: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + minimatch@^5.0.1: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" @@ -7358,10 +7433,10 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== -node-forge@^1.3.2: - version "1.3.3" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751" - integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg== +node-forge@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" + integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== node-int64@^0.4.0: version "0.4.0" @@ -8153,13 +8228,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - range-parser@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" @@ -8457,7 +8525,7 @@ rxjs@^7.4.0, rxjs@^7.8.0: dependencies: tslib "^2.1.0" -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -8699,12 +8767,10 @@ send@~0.19.0, send@~0.19.1: range-parser "~1.2.1" statuses "~2.0.2" -serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" - integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== - dependencies: - randombytes "^2.1.0" +serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^6.0.2, serialize-javascript@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.5.tgz#c798cc0552ffbb08981914a42a8756e339d0d5b1" + integrity sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw== serve-handler@6.1.6: version "6.1.6"