Skip to content

Commit ca23f85

Browse files
feat: add action catalog endpoint and improve OpenAPI spec handling
1 parent 1c26e78 commit ca23f85

8 files changed

Lines changed: 184 additions & 28 deletions

File tree

docs/enclave/api-reference/enclavejs-broker.mdx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,93 @@ Submit tool execution result.
193193
}
194194
```
195195

196+
### GET /code/actions
197+
198+
Returns the current action catalog derived from connected OpenAPI sources.
199+
200+
**Response:**
201+
```ts
202+
interface CatalogResponse {
203+
/** Available actions from all connected OpenAPI sources */
204+
actions: CatalogAction[];
205+
/** Connected OpenAPI service descriptors */
206+
services: CatalogService[];
207+
/** Catalog version — changes when actions are added or removed */
208+
version: string;
209+
}
210+
211+
interface CatalogAction {
212+
/** Tool name (e.g., "user-service_listUsers") */
213+
name: string;
214+
/** Human-readable description from the OpenAPI spec */
215+
description?: string;
216+
/** JSON Schema for the tool's input parameters */
217+
inputSchema?: Record<string, unknown>;
218+
/** Name of the service this action belongs to */
219+
service: string;
220+
/** Tags from the OpenAPI operation */
221+
tags?: string[];
222+
/** Whether the operation is deprecated */
223+
deprecated?: boolean;
224+
}
225+
226+
interface CatalogService {
227+
/** Service name (from OpenApiSourceConfig.name) */
228+
name: string;
229+
/** Internal spec URL */
230+
specUrl: string;
231+
/** ISO 8601 timestamp of the last successful spec poll */
232+
lastUpdated: string;
233+
/** Number of actions from this service */
234+
actionCount: number;
235+
}
236+
```
237+
238+
**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.
239+
240+
**Example:**
241+
```bash
242+
curl http://localhost:3001/code/actions
243+
```
244+
245+
```json
246+
{
247+
"actions": [
248+
{
249+
"name": "user-service_listUsers",
250+
"description": "List all users",
251+
"service": "user-service"
252+
},
253+
{
254+
"name": "user-service_getUser",
255+
"description": "Get user by ID",
256+
"service": "user-service"
257+
}
258+
],
259+
"services": [
260+
{
261+
"name": "user-service",
262+
"specUrl": "",
263+
"lastUpdated": "2026-03-31T12:00:00.000Z",
264+
"actionCount": 2
265+
}
266+
],
267+
"version": "a1b2c3d4..."
268+
}
269+
```
270+
271+
**Wiring:** Use `CatalogHandler` to register this route:
272+
```ts
273+
import { CatalogHandler } from '@enclave-vm/broker';
274+
275+
const catalog = new CatalogHandler(toolRegistry, openApiSources);
276+
for (const route of catalog.getRoutes()) {
277+
app[route.method.toLowerCase()](route.path, route.handler);
278+
}
279+
```
280+
281+
The types `CatalogAction`, `CatalogService`, `CatalogResponse`, and `CatalogHandler` are all exported from the `@enclave-vm/broker` package.
282+
196283
### GET /health
197284

198285
Health check endpoint.

libs/broker/src/broker-session.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -231,17 +231,22 @@ export class BrokerSession {
231231
stdoutBytes: this.stdoutBytes,
232232
};
233233

234-
// If session was cancelled while running, let the catch path handle it
234+
// Session was cancelled/terminated while enclave was running
235235
if (this.isTerminal()) {
236+
const cancelError = {
237+
code: this._deadlineExceeded ? 'DEADLINE_EXCEEDED' : 'SESSION_CANCELLED',
238+
message: 'Session was cancelled',
239+
};
240+
this.emitter.emitFinalError(cancelError, eventStats);
236241
result = {
237242
success: false,
238-
error: { message: 'Session was cancelled', name: 'Error', code: 'SESSION_CANCELLED' },
243+
error: { message: cancelError.message, name: 'Error', code: cancelError.code },
239244
stats,
240245
finalState: 'cancelled',
241246
};
242247
} else if (enclaveResult.success) {
243248
this._state = 'completed';
244-
this.emitter.emitFinalSuccess(enclaveResult.value, eventStats);
249+
this.emitter.emitFinalSuccess(enclaveResult.value, eventStats, this.getPartialErrors());
245250
result = {
246251
success: true,
247252
value: enclaveResult.value,
@@ -254,7 +259,7 @@ export class BrokerSession {
254259
code: enclaveResult.error?.code ?? 'EXECUTION_ERROR',
255260
message: enclaveResult.error?.message ?? 'Execution failed',
256261
};
257-
this.emitter.emitFinalError(errorInfo, eventStats);
262+
this.emitter.emitFinalError(errorInfo, eventStats, this.getPartialErrors());
258263
result = {
259264
success: false,
260265
error: {
@@ -299,7 +304,7 @@ export class BrokerSession {
299304
: ((err as Error & { code?: string }).code ?? 'EXECUTION_ERROR'),
300305
message: err.message,
301306
};
302-
this.emitter.emitFinalError(errorInfo, eventStats);
307+
this.emitter.emitFinalError(errorInfo, eventStats, this.getPartialErrors());
303308
result = {
304309
success: false,
305310
error: {
@@ -417,14 +422,13 @@ export class BrokerSession {
417422
}
418423

419424
/**
420-
* Create a custom event with proper base fields.
421-
* Uses the emitter's current seq (no auto-increment for custom events).
425+
* Create a custom event with proper base fields and unique sequence number.
422426
*/
423427
private makeCustomEvent(type: string, payload: Record<string, unknown>): StreamEvent {
424428
return {
425429
protocolVersion: PROTOCOL_VERSION,
426430
sessionId: this.sessionId,
427-
seq: this.seq,
431+
seq: this.emitter.nextSeq(),
428432
type,
429433
payload,
430434
} as unknown as StreamEvent;
@@ -467,6 +471,7 @@ export class BrokerSession {
467471
toolCallCount: this.toolCallCount,
468472
stdoutBytes: this.stdoutBytes,
469473
},
474+
this.getPartialErrors(),
470475
);
471476
}
472477
}

libs/broker/src/openapi/catalog-handler.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,11 @@ export class CatalogHandler {
116116
});
117117
}
118118

119-
// Version is a hash of all source hashes
120-
const versionParts = this.sources.map((s) => s.getStats().specHash).join(':');
119+
// Version is a deterministic hash of all source hashes (sorted for stability)
120+
const versionParts = this.sources
121+
.map((s) => s.getStats().specHash)
122+
.sort()
123+
.join(':');
121124
const version = versionParts || 'empty';
122125

123126
return { actions, services, version };
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Cross-platform SHA-256 hashing utility.
3+
*
4+
* Uses Web Crypto API (crypto.subtle) which is available in both
5+
* Node.js 18+ and modern browsers.
6+
*
7+
* @packageDocumentation
8+
*/
9+
10+
/**
11+
* Compute SHA-256 hex digest of a string.
12+
*/
13+
export async function sha256Hex(data: string): Promise<string> {
14+
const encoded = new TextEncoder().encode(data);
15+
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded);
16+
const hashArray = new Uint8Array(hashBuffer);
17+
return Array.from(hashArray)
18+
.map((b) => b.toString(16).padStart(2, '0'))
19+
.join('');
20+
}

libs/broker/src/openapi/openapi-spec-poller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import { EventEmitter } from 'events';
11-
import { createHash } from 'node:crypto';
11+
import { sha256Hex } from './hash-utils';
1212

1313
/**
1414
* Change detection strategy.
@@ -243,7 +243,7 @@ export class OpenApiSpecPoller extends EventEmitter {
243243

244244
if (this._stopped) return;
245245

246-
const hash = createHash('sha256').update(body).digest('hex');
246+
const hash = await sha256Hex(body);
247247

248248
if (this.lastHash && this.lastHash === hash) {
249249
this.emit('unchanged');

libs/broker/src/openapi/openapi-tool-loader.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import { z } from 'zod';
11-
import { createHash } from 'node:crypto';
11+
import { sha256Hex } from './hash-utils';
1212
import type { ToolDefinition, ToolContext } from '../tool-registry';
1313

1414
/**
@@ -72,10 +72,10 @@ export class OpenApiToolLoader {
7272
private readonly options: LoaderOptions;
7373
private readonly auth?: UpstreamAuth;
7474

75-
private constructor(spec: Record<string, unknown>, options: LoaderOptions = {}, auth?: UpstreamAuth) {
75+
private constructor(spec: Record<string, unknown>, hash: string, options: LoaderOptions = {}, auth?: UpstreamAuth) {
7676
this.options = options;
7777
this.auth = auth;
78-
this.specHash = createHash('sha256').update(JSON.stringify(spec)).digest('hex');
78+
this.specHash = hash;
7979
this.loadFromSpec(spec);
8080
}
8181

@@ -103,7 +103,8 @@ export class OpenApiToolLoader {
103103
throw new Error(`Failed to fetch OpenAPI spec from ${url}: ${response.status}`);
104104
}
105105
const spec = (await response.json()) as Record<string, unknown>;
106-
return new OpenApiToolLoader(spec, { ...options, baseUrl: options?.baseUrl ?? new URL(url).origin }, auth);
106+
const hash = await sha256Hex(JSON.stringify(spec));
107+
return new OpenApiToolLoader(spec, hash, { ...options, baseUrl: options?.baseUrl ?? new URL(url).origin }, auth);
107108
}
108109

109110
/**
@@ -114,7 +115,8 @@ export class OpenApiToolLoader {
114115
options?: LoaderOptions,
115116
auth?: UpstreamAuth,
116117
): Promise<OpenApiToolLoader> {
117-
return new OpenApiToolLoader(spec, options, auth);
118+
const hash = await sha256Hex(JSON.stringify(spec));
119+
return new OpenApiToolLoader(spec, hash, options, auth);
118120
}
119121

120122
/**
@@ -217,10 +219,11 @@ export class OpenApiToolLoader {
217219
private buildArgsSchema(op: ParsedOperation): z.ZodType {
218220
const shape: Record<string, z.ZodType> = {};
219221

220-
// Add parameters
222+
// Add parameters with OpenAPI type mapping
221223
if (op.parameters) {
222224
for (const param of op.parameters) {
223-
shape[param.name] = param.required ? z.string() : z.string().optional();
225+
const baseType = this.mapOpenApiType(param.schema);
226+
shape[param.name] = param.required ? baseType : baseType.optional();
224227
}
225228
}
226229

@@ -234,6 +237,34 @@ export class OpenApiToolLoader {
234237
return Object.keys(shape).length > 0 ? z.object(shape) : z.record(z.string(), z.unknown());
235238
}
236239

240+
/**
241+
* Map an OpenAPI schema type to the corresponding Zod type.
242+
*/
243+
private mapOpenApiType(schema?: Record<string, unknown>): z.ZodType {
244+
if (!schema || !schema['type']) return z.string();
245+
246+
const type = schema['type'] as string;
247+
const enumValues = schema['enum'] as string[] | undefined;
248+
249+
switch (type) {
250+
case 'integer':
251+
return z.number().int();
252+
case 'number':
253+
return z.number();
254+
case 'boolean':
255+
return z.boolean();
256+
case 'array':
257+
return z.array(this.mapOpenApiType(schema['items'] as Record<string, unknown> | undefined));
258+
case 'string':
259+
if (enumValues && enumValues.length > 0) {
260+
return z.enum(enumValues as [string, ...string[]]);
261+
}
262+
return z.string();
263+
default:
264+
return z.string();
265+
}
266+
}
267+
237268
/**
238269
* Create a handler function for an OpenAPI operation.
239270
*/
@@ -267,9 +298,10 @@ export class OpenApiToolLoader {
267298
url += `?${queryString}`;
268299
}
269300

270-
// Build request headers
301+
// Build request headers (only set Content-Type for methods with a body)
302+
const hasBody = ['post', 'put', 'patch'].includes(op.method) && params['body'] != null;
271303
const requestHeaders: Record<string, string> = {
272-
'Content-Type': 'application/json',
304+
...(hasBody && { 'Content-Type': 'application/json' }),
273305
...headers,
274306
};
275307

@@ -304,8 +336,7 @@ export class OpenApiToolLoader {
304336
signal: context.signal,
305337
};
306338

307-
// Add body for methods that support it
308-
if (['post', 'put', 'patch'].includes(op.method) && params['body']) {
339+
if (hasBody) {
309340
fetchOptions.body = JSON.stringify(params['body']);
310341
}
311342

libs/client/src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* @packageDocumentation
77
*/
88

9-
import type { SessionId, StreamEvent, SessionLimits } from '@enclave-vm/types';
9+
import type { SessionId, StreamEvent, SessionLimits, ErrorPayload, ToolProgressPhase } from '@enclave-vm/types';
1010

1111
/**
1212
* Client configuration
@@ -124,12 +124,12 @@ export interface SessionEventHandlers {
124124
/**
125125
* Called when a partial result arrives
126126
*/
127-
onPartialResult?: (path: string[], data?: unknown, error?: unknown, hasNext?: boolean) => void;
127+
onPartialResult?: (path: string[], data?: unknown, error?: ErrorPayload, hasNext?: boolean) => void;
128128

129129
/**
130130
* Called when tool progress is reported
131131
*/
132-
onToolProgress?: (callId: string, phase: string, elapsedMs: number) => void;
132+
onToolProgress?: (callId: string, phase: ToolProgressPhase, elapsedMs: number) => void;
133133

134134
/**
135135
* Called when deadline is exceeded

0 commit comments

Comments
 (0)