From dabe51f70cfbd031adae5409a4864cabded34a46 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:44:20 -0700 Subject: [PATCH 1/2] Add executor generate: typed TypeScript client for the tool catalog Running executor generate against an instance writes one self-contained TypeScript file: a dependency-free Proxy-based runtime client plus full input/output types for every visible tool, so client.github.org.main.issues.create({ title }) is typed end to end and executes through the server's /api/executions endpoint (auth, policies, approvals included). New pieces: - tools.export SDK surface + GET /tools/export: the bulk schema-bearing catalog read (schemas + trimmed shared $defs, grouped per connection, policy-filtered), one request regardless of catalog size. - compileToolChunkTypeScript: compiles many tools per compiler pass. Per-tool passes pay fixed overhead thousands of times and one whole-catalog pass is super-linear (30s+ at 10k tools); 200-tool chunks generate a 10k-tool catalog in ~400ms. - generateToolProxySource: assembles the generated file (type-only namespaces per connection, ExecutorTools path tree, embedded runtime). - executor generate CLI command with --output/--integration/--connection/ --include-static, reusing the server-target and auth machinery. Tested down to the wire: generated source is typechecked with the real compiler (valid calls accept, invalid reject), the transpiled runtime is imported and run against a fake fetch (completed/paused/injection paths), and a scale test ingests a 10,000-operation OpenAPI spec through addSpec and proves export + generate + strict typecheck hold up with regression tripwires on time. --- .changeset/typed-api-proxy-generate.md | 16 + apps/cli/src/main.ts | 103 ++++ apps/docs/local/cli.mdx | 25 + packages/core/api/src/handlers/tools.ts | 15 + packages/core/api/src/tools/api.ts | 12 + packages/core/execution/src/promise.ts | 2 + packages/core/sdk/src/executor.ts | 132 ++++- packages/core/sdk/src/index.ts | 23 +- packages/core/sdk/src/schema-types.ts | 108 ++++ packages/core/sdk/src/shared.ts | 8 +- packages/core/sdk/src/typegen.test.ts | 516 +++++++++++++++++ packages/core/sdk/src/typegen.ts | 528 ++++++++++++++++++ packages/core/sdk/src/types.ts | 37 +- .../openapi/src/sdk/typegen-scale.test.ts | 213 +++++++ 14 files changed, 1733 insertions(+), 5 deletions(-) create mode 100644 .changeset/typed-api-proxy-generate.md create mode 100644 packages/core/sdk/src/typegen.test.ts create mode 100644 packages/core/sdk/src/typegen.ts create mode 100644 packages/plugins/openapi/src/sdk/typegen-scale.test.ts diff --git a/.changeset/typed-api-proxy-generate.md b/.changeset/typed-api-proxy-generate.md new file mode 100644 index 000000000..6529dec2a --- /dev/null +++ b/.changeset/typed-api-proxy-generate.md @@ -0,0 +1,16 @@ +--- +"@executor-js/sdk": minor +"executor": minor +--- + +Add `executor generate`: emit a typed TypeScript client for an instance's tool catalog. + +Running `executor generate` against a server writes a single self-contained +file (default `executor.gen.ts`) with a dependency-free runtime client and +full input/output types for every visible tool, so +`client.github.org.main.issues.create({ title })` is typed end to end and +calls go through the server's execution endpoint (auth, policies, and +approvals included). New `GET /tools/export` endpoint and +`executor.tools.export()` SDK surface return the whole schema-bearing catalog +in one read; generation compiles schemas in chunks and stays fast at +10,000-tool scale. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index c6e4df0e0..a0f37b43a 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -69,6 +69,8 @@ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import { ExecutorApi, checkForUpdate } from "@executor-js/api"; import { + ConnectionName, + IntegrationSlug, getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, normalizeExecutorServerOrigin, @@ -77,6 +79,7 @@ import { type ExecutorServerConnection, type ExecutorServerConnectionInput, } from "@executor-js/sdk/shared"; +import { generateToolProxySource } from "@executor-js/sdk/core"; import { decodeAccessTokenClaims, discoverCliLogin, @@ -2710,6 +2713,105 @@ const mcpCommand = Command.make( }), ).pipe(Command.withDescription("Start an MCP server over stdio")); +// --------------------------------------------------------------------------- +// Generate — emit a typed TypeScript client for the instance's tool catalog +// --------------------------------------------------------------------------- + +const generateCommand = Command.make( + "generate", + { + output: Options.string("output").pipe( + Options.withAlias("o"), + Options.withDefault("executor.gen.ts"), + Options.withDescription("Path of the generated TypeScript file."), + ), + integration: Options.string("integration").pipe( + Options.optional, + Options.withDescription("Only generate tools from this integration slug."), + ), + connection: Options.string("connection").pipe( + Options.optional, + Options.withDescription("Only generate tools from this connection name."), + ), + includeStatic: Options.boolean("include-static").pipe( + Options.withDefault(false), + Options.withDescription( + "Include Executor's built-in static tools (executor.*, search, describe.*).", + ), + ), + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ output, integration, connection, includeStatic, baseUrl, server, scope }) => + Effect.gen(function* () { + applyScope(scope); + const target = serverTargetFromOptions({ baseUrl, server }); + const serverConnection = yield* resolveExecutorServerConnection(target); + const client = yield* makeApiClient(serverConnection); + + const catalog = yield* client.tools + .export({ + query: { + ...(Option.isSome(integration) + ? { integration: IntegrationSlug.make(integration.value) } + : {}), + ...(Option.isSome(connection) + ? { connection: ConnectionName.make(connection.value) } + : {}), + }, + }) + .pipe(Effect.mapError(toError)); + + const filtered = includeStatic + ? catalog + : { + connections: catalog.connections + .map((entry) => ({ + ...entry, + tools: entry.tools.filter((tool) => tool.static !== true), + })) + .filter((entry) => entry.tools.length > 0), + }; + + const generated = yield* Effect.try({ + try: () => generateToolProxySource(filtered), + catch: (cause) => + cause instanceof Error + ? cause + : new Error(`Failed to generate TypeScript source: ${String(cause)}`), + }); + + if (generated.toolCount === 0) { + return yield* Effect.fail( + new Error( + [ + "No tools matched, nothing to generate.", + `Server: ${serverConnection.origin}`, + `Check filters, or add an integration first: ${cliPrefix} web`, + ].join("\n"), + ), + ); + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* PlatformPath.Path; + const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output); + yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }); + yield* fs.writeFileString(outputPath, generated.source); + + console.log( + `Generated ${outputPath} (${generated.toolCount} tools, ${generated.connectionCount} connections).`, + ); + console.log(""); + console.log("Use it:"); + console.log(` import { createExecutorClient } from "./${path.basename(output, ".ts")}";`); + console.log(` const executor = createExecutorClient();`); + }).pipe(Effect.mapError(toError)), +).pipe( + Command.withDescription("Generate a typed TypeScript client for this instance's tool catalog"), +); + // --------------------------------------------------------------------------- // Service — register the daemon with the OS so it survives app-quit + restart // --------------------------------------------------------------------------- @@ -3112,6 +3214,7 @@ const root = Command.make("executor").pipe( daemonCommand, serviceCommand, mcpCommand, + generateCommand, openCommand, docsCommand, ] as const), diff --git a/apps/docs/local/cli.mdx b/apps/docs/local/cli.mdx index c53c8d0f1..c4435ce36 100644 --- a/apps/docs/local/cli.mdx +++ b/apps/docs/local/cli.mdx @@ -109,3 +109,28 @@ resume it: ```bash executor resume --execution-id exec_123 ``` + +## Generate a typed client + +`executor generate` writes a single self-contained TypeScript file with full +input/output types for every tool in your catalog, plus a dependency-free +runtime client. Drop it into any TypeScript project and every call is typed +end to end, while still executing through Executor (auth, policies, and +approvals included): + +```bash +executor generate # writes executor.gen.ts +executor generate -o src/executor.ts # custom output path +executor generate --integration github # only one integration +``` + +```ts +import { createExecutorClient } from "./executor.gen"; + +const executor = createExecutorClient(); // EXECUTOR_API_KEY / EXECUTOR_AUTH_TOKEN +const created = await executor.github.org.main.issues.create({ title: "Hi" }); +if (created.ok) console.log(created.data.number); +``` + +Calls that pause for approval throw `ExecutorPausedError` with the approval +URL. Re-run `executor generate` whenever your catalog changes. diff --git a/packages/core/api/src/handlers/tools.ts b/packages/core/api/src/handlers/tools.ts index 8e5818d7c..486747b8f 100644 --- a/packages/core/api/src/handlers/tools.ts +++ b/packages/core/api/src/handlers/tools.ts @@ -49,5 +49,20 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler return schema; }), ), + ) + .handle("export", ({ query }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.tools.export({ + integration: query.integration, + owner: query.owner, + connection: query.connection, + query: query.query, + includeAnnotations: query.includeAnnotations === "true", + includeBlocked: query.includeBlocked === "true", + }); + }), + ), ), ); diff --git a/packages/core/api/src/tools/api.ts b/packages/core/api/src/tools/api.ts index 767c6609d..5f5b6cf93 100644 --- a/packages/core/api/src/tools/api.ts +++ b/packages/core/api/src/tools/api.ts @@ -16,6 +16,7 @@ import { InternalError, Owner, ToolAddress, + ToolCatalogExport, ToolNotFoundError, ToolSchemaView, } from "@executor-js/sdk/shared"; @@ -82,4 +83,15 @@ export const ToolsApi = HttpApiGroup.make("tools") success: ToolSchemaView, error: [InternalError, ToolNotFound], }), + ) + .add( + // Bulk schema-bearing read for codegen (`executor generate`): every + // visible tool's input/output JSON schema grouped per connection with the + // connection's shared `$defs`. One request regardless of catalog size — + // per-tool `schema` round trips do not scale to 10k-tool catalogs. + HttpApiEndpoint.get("export", "/tools/export", { + query: ListToolsQuery, + success: ToolCatalogExport, + error: InternalError, + }), ); diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index ee6eeda50..167f21e01 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -109,6 +109,8 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => { fromPromise(() => pe.tools.list(filter)), schema: (address: Parameters[0]) => fromPromise(() => pe.tools.schema(address)), + export: (filter?: Parameters[0]) => + fromPromise(() => pe.tools.export(filter)), }, providers: { list: () => fromPromise(() => pe.providers.list()), diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 530462a8a..d856f987f 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -136,7 +136,13 @@ import { ORG_SUBJECT, type ExecutorOwnerPolicyContext, } from "./owner-policy"; -import { ToolSchemaView, type IntegrationDetectionResult } from "./types"; +import { + ToolCatalogExport, + ToolSchemaView, + type IntegrationDetectionResult, + type ToolCatalogConnectionExport, + type ToolCatalogToolExport, +} from "./types"; import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool"; import { buildToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; @@ -304,6 +310,9 @@ export type Executor = { readonly tools: { readonly list: (filter?: ToolListFilter) => Effect.Effect; readonly schema: (address: ToolAddress) => Effect.Effect; + /** Bulk schema-bearing read for codegen: every visible tool's input/output + * JSON schema, grouped per connection with trimmed shared `$defs`. */ + readonly export: (filter?: ToolListFilter) => Effect.Effect; }; readonly providers: { @@ -2752,6 +2761,126 @@ export const createExecutor = => + Effect.gen(function* () { + yield* syncStaleConnectionTools; + const rows = yield* core.findMany("tool", { + where: (b: AnyCb) => + b.and( + filter?.integration === undefined + ? true + : b("integration", "=", String(filter.integration)), + filter?.owner === undefined ? true : b("owner", "=", filter.owner), + filter?.connection === undefined + ? true + : b("connection", "=", String(filter.connection)), + ), + }); + const includeBlocked = filter?.includeBlocked ?? false; + const policyRules = yield* listActivePolicyRuleSet(); + + type ConnectionGroup = { + readonly owner: Owner; + readonly integration: IntegrationSlug; + readonly connection: ConnectionName; + readonly tools: ToolCatalogToolExport[]; + }; + const groups = new Map(); + const groupFor = (tool: Tool): ConnectionGroup => { + const key = `${tool.owner}${tool.integration}${tool.connection}`; + let group = groups.get(key); + if (!group) { + group = { + owner: tool.owner, + integration: tool.integration, + connection: tool.connection, + tools: [], + }; + groups.set(key, group); + } + return group; + }; + + const visible = (tool: Tool): Effect.Effect => + Effect.gen(function* () { + if (!matchesToolFilter(tool, filter)) return false; + if (includeBlocked) return true; + const effective = yield* resolvePolicyFromRuleSet( + normalizedPolicyId(tool), + policyRules, + tool.annotations?.requiresApproval, + ); + return effective.action !== "block"; + }); + + for (const row of rows) { + const tool = rowToTool(row); + if (!(yield* visible(tool))) continue; + groupFor(tool).tools.push({ + address: tool.address, + name: String(tool.name), + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + }); + } + for (const entry of staticTools.values()) { + const tool = staticToolToTool(entry); + if (!(yield* visible(tool))) continue; + groupFor(tool).tools.push({ + address: tool.address, + name: String(tool.name), + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + static: true, + }); + } + + const connectionsOut: ToolCatalogConnectionExport[] = []; + for (const group of groups.values()) { + // Static pseudo-connections have no definition rows; skip the query. + const isStatic = group.tools.every((tool) => tool.static === true); + let definitions: Record | undefined; + if (!isStatic) { + const definitionRows = yield* core.findMany("definition", { + where: (b: AnyCb) => + b.and( + byOwner(group.owner)(b), + b("integration", "=", String(group.integration)), + b("connection", "=", String(group.connection)), + ), + }); + const defs = new Map(); + for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema)); + const referenced = collectReferencedDefinitions( + group.tools.flatMap((tool) => [tool.inputSchema, tool.outputSchema]), + defs, + ); + if (Object.keys(referenced).length > 0) { + definitions = referenced; + } + } + connectionsOut.push({ + owner: group.owner, + integration: group.integration, + connection: group.connection, + tools: group.tools, + ...(definitions !== undefined ? { definitions } : {}), + }); + } + + return ToolCatalogExport.make({ connections: connectionsOut }); + }); + // ------------------------------------------------------------------ // Providers // ------------------------------------------------------------------ @@ -3465,6 +3594,7 @@ export const createExecutor = ; }; +// --------------------------------------------------------------------------- +// Chunked tool compilation — many tools per compiler pass. +// +// `buildToolTypeScriptPreview` compiles ONE tool per pass; at catalog scale +// (the `executor generate` typegen walks every tool, potentially 10k+) the +// per-pass overhead dominates and compile time grows super-linearly with the +// number of declarations in a single pass. This entry point compiles a CHUNK +// of tools in one pass by wrapping every tool's input/output as a property of +// one root object, then splits the compiled root interface back into per-tool +// type strings. Callers pick the chunk size; ~200 keeps each pass linear. +// --------------------------------------------------------------------------- + +export type ToolChunkSchemaEntry = { + readonly key: string; + readonly inputSchema?: unknown; + readonly outputSchema?: unknown; +}; + +export type ToolChunkTypeScriptEntry = { + readonly inputTypeScript?: string; + readonly outputTypeScript?: string; +}; + +export type ToolChunkTypeScript = { + readonly tools: ReadonlyMap; + readonly definitions: Record; +}; + +const CHUNK_INPUT_SUFFIX = "__in"; +const CHUNK_OUTPUT_SUFFIX = "__out"; + +/** + * Compile a chunk of tools' input/output schemas in one compiler pass. + * + * `key`s must be unique valid identifiers within the chunk (the caller indexes + * them, e.g. `t0`, `t1`, …). Shared `$defs` are compiled once per chunk and + * returned as named `definitions`; the per-tool type strings reference them by + * name. Throws when the underlying compiler rejects a schema — callers decide + * whether to retry tool-by-tool or degrade to `unknown`. + */ +export const compileToolChunkTypeScript = ( + tools: ReadonlyArray, + defs: ReadonlyMap, + options: TypeScriptRenderOptions = {}, +): ToolChunkTypeScript => { + const properties: Array = []; + for (const tool of tools) { + if (tool.inputSchema !== undefined) { + properties.push([`${tool.key}${CHUNK_INPUT_SUFFIX}`, tool.inputSchema]); + } + if (tool.outputSchema !== undefined) { + properties.push([`${tool.key}${CHUNK_OUTPUT_SUFFIX}`, tool.outputSchema]); + } + } + if (properties.length === 0) { + return { + tools: new Map(tools.map((tool) => [tool.key, {}])), + definitions: {}, + }; + } + + const wrappedSchema = buildWrappedObjectSchema(properties, defs); + const source = compile(wrappedSchema, ROOT_WRAPPER_NAME, compilerOptionsFrom(options)); + const declarations = parseGeneratedDeclarations(source); + const rootDeclaration = declarations.find( + (declaration) => declaration.name === ROOT_WRAPPER_NAME, + ); + const body = rootDeclaration?.kind === "interface" ? rootDeclaration.body : ""; + + // Single forward pass: the compiler emits root properties in insertion + // order, so a running offset keeps extraction linear in the body size and + // immune to one key being a substring of a later one. + let offset = 0; + const extractNext = (propertyName: string): string | null => { + const propertyIndex = body.indexOf(propertyName, offset); + if (propertyIndex < 0) { + return null; + } + const colonIndex = body.indexOf(":", propertyIndex + propertyName.length); + if (colonIndex < 0) { + return null; + } + const end = findTypeAliasEnd(body, colonIndex + 1); + if (end < 0) { + return null; + } + offset = end; + return body.slice(colonIndex + 1, end).trim(); + }; + + const compiled = new Map(); + for (const tool of tools) { + const input = + tool.inputSchema !== undefined ? extractNext(`${tool.key}${CHUNK_INPUT_SUFFIX}`) : null; + const output = + tool.outputSchema !== undefined ? extractNext(`${tool.key}${CHUNK_OUTPUT_SUFFIX}`) : null; + compiled.set(tool.key, { + ...(input ? { inputTypeScript: compactTypeScript(input) } : {}), + ...(output ? { outputTypeScript: compactTypeScript(output) } : {}), + }); + } + + return { + tools: compiled, + definitions: getDefinitionsFromDeclarations(declarations), + }; +}; + export const buildToolTypeScriptPreview = async (input: { inputSchema?: unknown; outputSchema?: unknown; diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index b4a543b1e..7d3851448 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -97,7 +97,13 @@ export { export type { ToolPolicyAction } from "./core-schema"; // Schema-side views + onboarding autodetect. -export { ToolSchemaView, IntegrationDetectionResult } from "./types"; +export { + ToolCatalogConnectionExport, + ToolCatalogExport, + ToolCatalogToolExport, + ToolSchemaView, + IntegrationDetectionResult, +} from "./types"; export { decodeOAuthCallbackState, diff --git a/packages/core/sdk/src/typegen.test.ts b/packages/core/sdk/src/typegen.test.ts new file mode 100644 index 000000000..1dd0afac1 --- /dev/null +++ b/packages/core/sdk/src/typegen.test.ts @@ -0,0 +1,516 @@ +// --------------------------------------------------------------------------- +// generateToolProxySource: the `executor generate` backend. +// +// Covered here: +// - the generated source is valid strict TypeScript (checked with the real +// compiler) and its types reject wrong inputs, +// - the generated runtime client invokes tools through /api/executions and +// unwraps completed/paused/error responses (transpiled and imported, run +// against a fake fetch), +// - naming: hyphenated tool names, colliding sanitized names, description +// text that tries to escape its JSDoc comment, +// - resilience: a schema the compiler rejects degrades that one tool to +// `unknown` without poisoning its chunk, +// - `executor.tools.export` (the data source): schemas + trimmed shared +// $defs per connection, policy-filtered, static tools flagged. +// --------------------------------------------------------------------------- + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as ts from "typescript"; + +import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; +import { definePlugin } from "./plugin"; +import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; +import { generateToolProxySource } from "./typegen"; +import type { ToolCatalogExport } from "./types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const typecheck = (source: string, extraSource = ""): readonly string[] => { + const fileName = "generated.ts"; + const fullSource = extraSource.length > 0 ? `${source}\n${extraSource}` : source; + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], + }; + const host = ts.createCompilerHost(options); + const originalGetSourceFile = host.getSourceFile.bind(host); + const originalReadFile = host.readFile.bind(host); + const originalFileExists = host.fileExists.bind(host); + host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => + candidate === fileName + ? ts.createSourceFile(candidate, fullSource, languageVersion, true) + : originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile); + host.readFile = (candidate) => + candidate === fileName ? fullSource : originalReadFile(candidate); + host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate); + + const program = ts.createProgram([fileName], options, host); + return ts.getPreEmitDiagnostics(program).map((diagnostic) => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const position = + diagnostic.file && diagnostic.start !== undefined + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + : null; + return position + ? `${diagnostic.file!.fileName}:${position.line + 1}:${position.character + 1} ${message}` + : message; + }); +}; + +/** Transpile the generated TypeScript and import it as a real module. */ +const importGenerated = async (source: string): Promise> => { + const transpiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + }).outputText; + const dir = mkdtempSync(join(tmpdir(), "executor-typegen-")); + const file = join(dir, "generated.mjs"); + writeFileSync(file, transpiled); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: temp-dir cleanup around a dynamic import of the generated module + try { + return (await import(pathToFileURL(file).href)) as Record; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}; + +type ConnectionExport = ToolCatalogExport["connections"][number]; +type ToolExport = ConnectionExport["tools"][number]; + +const catalog = (connections: readonly ConnectionExport[]): ToolCatalogExport => ({ connections }); + +const connectionExport = (input: { + owner: "org" | "user"; + integration: string; + connection: string; + definitions?: Record; + tools: ReadonlyArray<{ + address: string; + name: string; + description?: string; + inputSchema?: unknown; + outputSchema?: unknown; + static?: boolean; + }>; +}): ConnectionExport => ({ + owner: input.owner, + integration: IntegrationSlug.make(input.integration), + connection: ConnectionName.make(input.connection), + ...(input.definitions !== undefined ? { definitions: input.definitions } : {}), + tools: input.tools.map( + (tool): ToolExport => ({ + ...tool, + address: ToolAddress.make(tool.address), + }), + ), +}); + +const githubConnection = connectionExport({ + owner: "org", + integration: "github", + connection: "main", + definitions: { + User: { + type: "object", + properties: { id: { type: "string" }, login: { type: "string" } }, + required: ["id"], + }, + }, + tools: [ + { + address: "tools.github.org.main.issues.create", + name: "issues.create", + description: "Create an issue", + inputSchema: { + type: "object", + properties: { title: { type: "string" }, assignee: { $ref: "#/$defs/User" } }, + required: ["title"], + }, + outputSchema: { + type: "object", + properties: { number: { type: "number" }, user: { $ref: "#/$defs/User" } }, + }, + }, + { + address: "tools.github.org.main.issues.list", + name: "issues.list", + description: "List issues */ } escape attempt", + inputSchema: { + type: "object", + properties: { state: { enum: ["open", "closed"] } }, + }, + }, + { + address: "tools.github.org.main.repos.get", + name: "repos.get", + description: "No schemas at all", + }, + ], +}); + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +describe("generateToolProxySource", { timeout: 60_000 }, () => { + it("emits strict-TypeScript-valid source with per-tool types", () => { + const generated = generateToolProxySource(catalog([githubConnection])); + expect(generated.toolCount).toBe(3); + expect(generated.connectionCount).toBe(1); + + const diagnostics = typecheck(generated.source); + expect(diagnostics).toEqual([]); + + expect(generated.source).toContain("export interface ExecutorTools"); + expect(generated.source).toContain("issues_create_Input"); + // Shared $defs become named, reused types instead of inlined copies. + expect(generated.source).toContain("export type User ="); + }); + + it("generated types accept correct calls and reject wrong ones", () => { + const generated = generateToolProxySource(catalog([githubConnection])); + + const validConsumer = ` + const client = createExecutorClient({ baseUrl: "http://localhost:4788" }); + async function main() { + const created = await client.github.org.main.issues.create({ title: "hi" }); + if (created.ok) { + const n: number | undefined = created.data.number; + void n; + } else { + const code: string = created.error.code; + void code; + } + await client.github.org.main.repos.get(); + await client.$call("github.org.main.repos.get", {}); + } + void main; + `; + expect(typecheck(generated.source, validConsumer)).toEqual([]); + + const invalidConsumer = ` + const client = createExecutorClient(); + async function main() { + // title is required and must be a string + await client.github.org.main.issues.create({ title: 42 }); + } + void main; + `; + expect(typecheck(generated.source, invalidConsumer)).not.toEqual([]); + }); + + it("keeps hyphenated path segments callable and dedupes colliding type names", () => { + const generated = generateToolProxySource( + catalog([ + connectionExport({ + owner: "user", + integration: "linear", + connection: "personal", + tools: [ + { + address: "tools.linear.user.personal.issue-create", + name: "issue-create", + inputSchema: { type: "object", properties: { title: { type: "string" } } }, + }, + { + // Sanitizes to the same identifier as issue-create. + address: "tools.linear.user.personal.issue_create", + name: "issue_create", + inputSchema: { type: "object", properties: { key: { type: "string" } } }, + }, + ], + }), + ]), + ); + + expect(typecheck(generated.source)).toEqual([]); + expect(generated.source).toContain('"issue-create":'); + // Both tools keep distinct input types despite the name collision. + expect(generated.source).toContain("issue_create_Input"); + expect(generated.source).toContain("issue_create_Input_2"); + }); + + it("degrades a compiler-rejected schema to unknown without poisoning its chunk", () => { + const generated = generateToolProxySource( + catalog([ + connectionExport({ + owner: "org", + integration: "demo", + connection: "main", + tools: [ + { + address: "tools.demo.org.main.good", + name: "good", + inputSchema: { type: "object", properties: { ok: { type: "boolean" } } }, + }, + { + address: "tools.demo.org.main.broken", + name: "broken", + // $ref to a definition that does not exist: the vendored + // compiler throws on dangling refs. + inputSchema: { $ref: "#/$defs/DoesNotExist" }, + }, + ], + }), + ]), + { chunkSize: 50 }, + ); + + expect(typecheck(generated.source)).toEqual([]); + // The good tool keeps its real type; the broken one degrades to unknown. + expect(generated.source).toContain("good_Input = { ok?: boolean; }"); + expect(generated.source).toContain("broken_Input = unknown"); + }); + + it("returns an empty interface for an empty catalog", () => { + const generated = generateToolProxySource(catalog([])); + expect(generated.toolCount).toBe(0); + expect(typecheck(generated.source)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Generated runtime client +// --------------------------------------------------------------------------- + +type FetchCall = { url: string; init: { headers: Record; body: string } }; + +const makeFakeFetch = (respond: (call: FetchCall) => unknown) => { + const calls: FetchCall[] = []; + const fetchImpl: typeof globalThis.fetch = async (url, init) => { + const call: FetchCall = { + url: String(url), + init: { + headers: (init?.headers ?? {}) as Record, + body: String(init?.body ?? ""), + }, + }; + calls.push(call); + return new Response(JSON.stringify(respond(call)), { status: 200 }); + }; + return { calls, fetchImpl }; +}; + +describe("generated runtime client", { timeout: 60_000 }, () => { + it("invokes tools through /api/executions and unwraps the outcome", async () => { + const generated = generateToolProxySource(catalog([githubConnection])); + const module = await importGenerated(generated.source); + const createClient = module.createExecutorClient as ( + options: Record, + ) => unknown; + + const { calls, fetchImpl } = makeFakeFetch(() => ({ + status: "completed", + text: "", + isError: false, + structured: { result: { ok: true, data: { number: 7 } } }, + })); + + // oxlint-disable-next-line executor/no-double-cast -- test boundary: the client is a dynamically imported Proxy; the cast pins the path this test dials + const client = createClient({ + baseUrl: "http://example.test:4788/", + token: "tok_123", + fetch: fetchImpl, + }) as unknown as { + github: { + org: { + main: { + issues: { create: (input: unknown) => Promise<{ ok: boolean; data?: unknown }> }; + }; + }; + }; + }; + + const outcome = await client.github.org.main.issues.create({ title: "hi" }); + expect(outcome).toEqual({ ok: true, data: { number: 7 } }); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("http://example.test:4788/api/executions"); + expect(calls[0]!.init.headers.authorization).toBe("Bearer tok_123"); + // oxlint-disable-next-line executor/no-json-parse -- test boundary: asserting on the raw request body the generated client produced + const body = JSON.parse(calls[0]!.init.body) as { code: string }; + expect(body.code).toContain('["github"]["org"]["main"]["issues"]["create"]'); + expect(body.code).toContain('{"title":"hi"}'); + }); + + it("throws ExecutorPausedError with the approval url on paused executions", async () => { + const generated = generateToolProxySource(catalog([githubConnection])); + const module = await importGenerated(generated.source); + const createClient = module.createExecutorClient as (options: Record) => { + $call: (path: string, input?: unknown) => Promise; + }; + + const { fetchImpl } = makeFakeFetch(() => ({ + status: "paused", + text: "Approval required", + structured: { executionId: "exec_42" }, + })); + + const client = createClient({ baseUrl: "http://example.test:4788", fetch: fetchImpl }); + const failure = await client.$call("github.org.main.issues.create", { title: "hi" }).then( + () => null, + (error: unknown) => error as Record, + ); + + expect(failure).not.toBeNull(); + expect(failure!.name).toBe("ExecutorPausedError"); + expect(failure!.executionId).toBe("exec_42"); + expect(failure!.approvalUrl).toBe("http://example.test:4788/resume/exec_42"); + }); + + it("rejects path segments that could break out of the invoke code", async () => { + const generated = generateToolProxySource(catalog([githubConnection])); + const module = await importGenerated(generated.source); + const createClient = module.createExecutorClient as (options: Record) => { + $call: (path: string, input?: unknown) => Promise; + }; + + const { calls, fetchImpl } = makeFakeFetch(() => ({})); + const client = createClient({ fetch: fetchImpl }); + + await expect(client.$call('bad"segment];evil()', {})).rejects.toMatchObject({ + name: "ExecutorRequestError", + }); + expect(calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// executor.tools.export: the catalog read behind the CLI +// --------------------------------------------------------------------------- + +const INTEG = IntegrationSlug.make("demo"); +const CONN = ConnectionName.make("main"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const demoPlugin = definePlugin(() => ({ + id: "demo" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [ + { + name: ToolName.make("inspect"), + description: "inspect", + inputSchema: { + type: "object", + properties: { pet: { $ref: "#/$defs/Pet" } }, + required: ["pet"], + }, + outputSchema: { $ref: "#/$defs/Owner" }, + }, + { name: ToolName.make("run"), description: "run" }, + ], + definitions: { + Pet: { type: "object", properties: { name: { type: "string" } } }, + Owner: { type: "object", properties: { pet: { $ref: "#/$defs/Pet" } } }, + Unreferenced: { type: "object", properties: { value: { type: "string" } } }, + }, + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Demo", + config: {}, + }), + }), +}))(); + +const setup = () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [memoryCredentialsPlugin(), demoPlugin] as const, + }); + yield* executor.demo.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: TEMPLATE, + value: "token", + }); + return executor; + }); + +describe("tools.export", { timeout: 60_000 }, () => { + it.effect("returns schemas grouped per connection with trimmed shared $defs", () => + Effect.gen(function* () { + const executor = yield* setup(); + const exported = yield* executor.tools.export({ integration: INTEG }); + + expect(exported.connections).toHaveLength(1); + const connection = exported.connections[0]!; + expect(String(connection.integration)).toBe("demo"); + expect(String(connection.connection)).toBe("main"); + expect(connection.tools.map((tool) => tool.name).sort()).toEqual(["inspect", "run"]); + + const inspect = connection.tools.find((tool) => tool.name === "inspect")!; + expect(inspect.inputSchema).toMatchObject({ type: "object" }); + expect(inspect.outputSchema).toEqual({ $ref: "#/$defs/Owner" }); + + // Referenced defs (Pet transitively via Owner) come along; unreferenced + // ones are trimmed. + expect(Object.keys(connection.definitions ?? {}).sort()).toEqual(["Owner", "Pet"]); + }), + ); + + it.effect("omits blocked tools like tools.list does", () => + Effect.gen(function* () { + const executor = yield* setup(); + yield* executor.policies.create({ + owner: "org", + pattern: "demo.org.main.run", + action: "block", + }); + + const exported = yield* executor.tools.export({ integration: INTEG }); + const names = exported.connections.flatMap((connection) => + connection.tools.map((tool) => tool.name), + ); + expect(names).toEqual(["inspect"]); + }), + ); + + it.effect("flags static tools and feeds a generatable catalog end to end", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [memoryCredentialsPlugin(), demoPlugin] as const, + coreTools: { webBaseUrl: "http://localhost:3000" }, + }); + yield* executor.demo.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: TEMPLATE, + value: "token", + }); + const exported = yield* executor.tools.export({ includeBlocked: true }); + + const staticTools = exported.connections + .flatMap((connection) => connection.tools) + .filter((tool) => tool.static === true); + expect(staticTools.length).toBeGreaterThan(0); + + const generated = generateToolProxySource(exported); + expect(generated.toolCount).toBeGreaterThanOrEqual(2); + expect(typecheck(generated.source)).toEqual([]); + }), + ); +}); diff --git a/packages/core/sdk/src/typegen.ts b/packages/core/sdk/src/typegen.ts new file mode 100644 index 000000000..fce4e8966 --- /dev/null +++ b/packages/core/sdk/src/typegen.ts @@ -0,0 +1,528 @@ +// --------------------------------------------------------------------------- +// Typed tool proxy generation: the `executor generate` backend. +// +// Turns a `ToolCatalogExport` (every visible tool's input/output JSON schema, +// grouped per connection with the connection's shared `$defs`) into ONE +// self-contained TypeScript source file: +// +// - a dependency-free runtime client (Proxy-based, so its size is constant +// no matter how many tools the catalog holds) that invokes tools through +// the server's `/api/executions` endpoint, +// - one non-instantiated (type-only, erasable) namespace per connection +// carrying that connection's shared definitions and per-tool input/output +// types, +// - an `ExecutorTools` interface mirroring the sandbox `tools.*` path tree, +// so `client.github.org.main.issues.create({...})` is fully typed. +// +// Scale is a first-class constraint: catalogs reach 10k+ tools. Schemas are +// compiled through `compileToolChunkTypeScript` (many tools per compiler pass; +// one pass per `chunkSize` tools) because per-tool passes pay fixed overhead +// 10k times and one whole-catalog pass grows super-linearly with declaration +// count. All source assembly appends to arrays and joins once. +// --------------------------------------------------------------------------- + +import { + compileToolChunkTypeScript, + type ToolChunkSchemaEntry, + type ToolChunkTypeScriptEntry, +} from "./schema-types"; +import type { ToolCatalogConnectionExport, ToolCatalogExport } from "./types"; + +export type GenerateToolProxyOptions = { + /** Tools compiled per compiler pass. The pass cost grows super-linearly + * with declaration count, so keep chunks small; 200 keeps each pass tens + * of milliseconds while amortizing per-pass overhead. */ + readonly chunkSize?: number; + /** Client factory name in the generated file. */ + readonly clientName?: string; +}; + +const DEFAULT_CHUNK_SIZE = 200; + +// --------------------------------------------------------------------------- +// Identifier helpers +// --------------------------------------------------------------------------- + +const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +const sanitizeIdentifier = (value: string): string => { + const cleaned = value.replace(/[^A-Za-z0-9_$]/g, "_"); + return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned.length > 0 ? cleaned : "_"; +}; + +const propertyKey = (segment: string): string => + IDENTIFIER_PATTERN.test(segment) ? segment : JSON.stringify(segment); + +const uniqueName = (base: string, used: Set): string => { + let candidate = base; + let counter = 2; + while (used.has(candidate)) { + candidate = `${base}_${counter}`; + counter += 1; + } + used.add(candidate); + return candidate; +}; + +const escapeJsDoc = (value: string): string => value.replace(/\*\//g, "*\\/"); + +// --------------------------------------------------------------------------- +// Per-connection compilation +// --------------------------------------------------------------------------- + +type CompiledTool = { + readonly pathSegments: readonly string[]; + readonly description?: string; + /** Fully-qualified type reference (`Ns.Name_Input`) or undefined when the + * tool has no input schema (callable with no argument). */ + readonly inputTypeRef?: string; + /** Fully-qualified type reference or "unknown". */ + readonly outputTypeRef: string; +}; + +type CompiledConnection = { + readonly namespaceName: string; + /** `export type Name = body;` lines inside the namespace. */ + readonly declarations: readonly string[]; + readonly tools: readonly CompiledTool[]; +}; + +/** Sandbox `tools.*` path for a tool: dynamic addresses drop the `tools.` + * proxy-root prefix; static addresses are already the callable path. */ +const toolPathSegments = ( + connection: ToolCatalogConnectionExport, + tool: ToolCatalogConnectionExport["tools"][number], +): readonly string[] => { + const address = String(tool.address); + const path = address.startsWith("tools.") ? address.slice("tools.".length) : address; + return path.split("."); +}; + +const compileConnection = ( + connection: ToolCatalogConnectionExport, + namespaceName: string, + chunkSize: number, +): CompiledConnection => { + const defs = new Map(Object.entries(connection.definitions ?? {})); + const tools = [...connection.tools].sort((a, b) => + String(a.address).localeCompare(String(b.address)), + ); + + // Chunked compile: keys are positional (`t`), unique and identifier- + // safe by construction. A failing chunk retries tool-by-tool so one broken + // schema degrades that tool to `unknown` instead of the whole chunk. + const compiled = new Map(); + const definitionDecls = new Map(); + const entries: ToolChunkSchemaEntry[] = tools.map((tool, index) => ({ + key: `t${index}`, + ...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}), + ...(tool.outputSchema !== undefined ? { outputSchema: tool.outputSchema } : {}), + })); + + const absorb = (result: ReturnType) => { + for (const [key, value] of result.tools) { + compiled.set(key, value); + } + for (const [name, body] of Object.entries(result.definitions)) { + if (!definitionDecls.has(name)) { + definitionDecls.set(name, body); + } + } + }; + + for (let start = 0; start < entries.length; start += chunkSize) { + const chunk = entries.slice(start, start + chunkSize); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the vendored json-schema-to-typescript compiler throws on invalid schemas; a failing chunk retries tool-by-tool + try { + absorb(compileToolChunkTypeScript(chunk, defs)); + } catch { + for (const entry of chunk) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: same throwing compiler; a single broken schema degrades to unknown + try { + absorb(compileToolChunkTypeScript([entry], defs)); + } catch { + compiled.set(entry.key, { + ...(entry.inputSchema !== undefined ? { inputTypeScript: "unknown" } : {}), + ...(entry.outputSchema !== undefined ? { outputTypeScript: "unknown" } : {}), + }); + } + } + } + } + + // Name per-tool types after the tool, avoiding collisions with definition + // names (which the compiled type bodies reference bare) and each other. + const usedNames = new Set(definitionDecls.keys()); + const declarations: string[] = []; + for (const [name, body] of [...definitionDecls.entries()].sort(([a], [b]) => + a.localeCompare(b), + )) { + declarations.push(` export type ${name} = ${body};`); + } + + const compiledTools: CompiledTool[] = tools.map((tool, index) => { + const entry = compiled.get(`t${index}`) ?? {}; + const base = sanitizeIdentifier(tool.name); + let inputTypeRef: string | undefined; + if (entry.inputTypeScript !== undefined) { + const inputName = uniqueName(`${base}_Input`, usedNames); + declarations.push(` export type ${inputName} = ${entry.inputTypeScript};`); + inputTypeRef = `${namespaceName}.${inputName}`; + } + let outputTypeRef = "unknown"; + if (entry.outputTypeScript !== undefined) { + const outputName = uniqueName(`${base}_Output`, usedNames); + declarations.push(` export type ${outputName} = ${entry.outputTypeScript};`); + outputTypeRef = `${namespaceName}.${outputName}`; + } + return { + pathSegments: toolPathSegments(connection, tool), + ...(tool.description !== undefined && tool.description.length > 0 + ? { description: tool.description } + : {}), + ...(inputTypeRef !== undefined ? { inputTypeRef } : {}), + outputTypeRef, + }; + }); + + return { namespaceName, declarations, tools: compiledTools }; +}; + +// --------------------------------------------------------------------------- +// Path tree +// --------------------------------------------------------------------------- + +type TreeNode = { + children: Map; + leaf?: CompiledTool; +}; + +const insertTool = (root: TreeNode, tool: CompiledTool): void => { + let node = root; + for (const segment of tool.pathSegments) { + let child = node.children.get(segment); + if (!child) { + child = { children: new Map() }; + node.children.set(segment, child); + } + node = child; + } + node.leaf = tool; +}; + +const leafType = (tool: CompiledTool): string => + tool.inputTypeRef !== undefined + ? `ExecutorToolFn<${tool.inputTypeRef}, ${tool.outputTypeRef}>` + : `ExecutorToolFnNoInput<${tool.outputTypeRef}>`; + +const emitNode = (node: TreeNode, indent: string, out: string[]): void => { + // A node can be both callable (a tool lives at this exact path) and a + // container (deeper tools share the prefix); emit the intersection. + const segments = [...node.children.entries()].sort(([a], [b]) => a.localeCompare(b)); + if (node.leaf) { + out.push(leafType(node.leaf)); + if (segments.length > 0) { + out.push(" & "); + } + } + if (segments.length === 0) { + return; + } + out.push("{\n"); + for (const [segment, child] of segments) { + if (child.leaf?.description !== undefined) { + out.push(`${indent} /** ${escapeJsDoc(child.leaf.description)} */\n`); + } + out.push(`${indent} ${propertyKey(segment)}: `); + emitNode(child, `${indent} `, out); + out.push(";\n"); + } + out.push(`${indent}}`); +}; + +// --------------------------------------------------------------------------- +// Runtime template: embedded verbatim in the generated file so the output +// has zero dependencies. Keep this plain TypeScript: no imports, no +// namespaces with runtime meaning, nothing beyond ES2020 + fetch. +// --------------------------------------------------------------------------- + +const runtimeTemplate = (clientName: string): string => `\ +export type ExecutorToolError = { + code: string; + message: string; + status?: number; + details?: unknown; + retryable?: boolean; +}; +export type ExecutorToolHttpMeta = { status: number; headers: { [k: string]: string } }; +export type ExecutorToolOutcome = + | { ok: true; data: O; http?: ExecutorToolHttpMeta } + | { ok: false; error: ExecutorToolError }; +export interface ExecutorCallOptions { + /** Approve approval-gated tools as the caller instead of pausing. */ + autoApprove?: boolean; + signal?: AbortSignal; +} +export type ExecutorToolFn = ( + input: I, + options?: ExecutorCallOptions, +) => Promise>; +export type ExecutorToolFnNoInput = ( + input?: Record, + options?: ExecutorCallOptions, +) => Promise>; + +export interface ExecutorClientOptions { + /** Executor server origin, e.g. "http://localhost:4788". */ + baseUrl?: string; + /** Bearer token. Defaults to EXECUTOR_API_KEY / EXECUTOR_AUTH_TOKEN. */ + token?: string; + fetch?: typeof globalThis.fetch; + headers?: Record; + /** Approve approval-gated tools for every call from this client. */ + autoApprove?: boolean; +} + +export class ExecutorRequestError extends Error { + readonly status: number | null; + readonly body: string; + constructor(message: string, status: number | null, body: string) { + super(message); + this.name = "ExecutorRequestError"; + this.status = status; + this.body = body; + } +} + +export class ExecutorPausedError extends Error { + readonly executionId: string | null; + readonly approvalUrl: string | null; + constructor(message: string, executionId: string | null, approvalUrl: string | null) { + super(message); + this.name = "ExecutorPausedError"; + this.executionId = executionId; + this.approvalUrl = approvalUrl; + } +} + +const DEFAULT_BASE_URL = "http://localhost:4788"; +const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; + +const readEnvToken = (): string | undefined => { + const env = (globalThis as { process?: { env?: Record } }).process + ?.env; + return env?.EXECUTOR_API_KEY ?? env?.EXECUTOR_AUTH_TOKEN; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const buildInvokeCode = (segments: readonly string[], input: unknown): string => { + const access = segments.map((segment) => \`[\${JSON.stringify(segment)}]\`).join(""); + return [ + \`const __args = \${JSON.stringify(input ?? {})};\`, + \`const __target = tools\${access};\`, + \`if (typeof __target !== "function") {\`, + \` throw new Error(\${JSON.stringify(\`Tool not found: \${segments.join(".")}\`)});\`, + \`}\`, + \`return await __target(__args);\`, + ].join("\\n"); +}; + +export interface ExecutorClientHandle { + /** Invoke any tool by dotted path, untyped. */ + $call: ( + path: string, + input?: Record, + options?: ExecutorCallOptions, + ) => Promise>; +} + +export type ExecutorClient = ExecutorTools & ExecutorClientHandle; + +export function ${clientName}(options: ExecutorClientOptions = {}): ExecutorClient { + const origin = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, ""); + const token = options.token ?? readEnvToken(); + const fetchImpl = options.fetch ?? globalThis.fetch; + + const invoke = async ( + segments: readonly string[], + input: unknown, + callOptions?: ExecutorCallOptions, + ): Promise> => { + for (const segment of segments) { + if (!PATH_SEGMENT_PATTERN.test(segment)) { + throw new ExecutorRequestError(\`Invalid tool path segment: \${segment}\`, null, ""); + } + } + if (input !== undefined && !isRecord(input)) { + throw new ExecutorRequestError("Tool input must be a JSON object", null, ""); + } + + const autoApprove = callOptions?.autoApprove ?? options.autoApprove; + const response = await fetchImpl(\`\${origin}/api/executions\`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: \`Bearer \${token}\` }: {}), + ...options.headers, + }, + body: JSON.stringify({ + code: buildInvokeCode(segments, input), + ...(autoApprove !== undefined ? { autoApprove }: {}), + }), + ...(callOptions?.signal ? { signal: callOptions.signal }: {}), + }); + + const bodyText = await response.text(); + if (!response.ok) { + throw new ExecutorRequestError( + \`Executor request failed with status \${response.status}\`, + response.status, + bodyText, + ); + } + + let payload: unknown; + try { + payload = JSON.parse(bodyText); + } catch { + throw new ExecutorRequestError("Executor returned a non-JSON response", null, bodyText); + } + if (!isRecord(payload)) { + throw new ExecutorRequestError("Executor returned an unexpected response", null, bodyText); + } + + if (payload.status === "paused") { + const structured = isRecord(payload.structured) ? payload.structured: {}; + const executionId = + typeof structured.executionId === "string" ? structured.executionId: null; + throw new ExecutorPausedError( + typeof payload.text === "string" ? payload.text: "Execution paused awaiting approval", + executionId, + executionId ? \`\${origin}/resume/\${encodeURIComponent(executionId)}\`: null, + ); + } + + if (payload.isError === true) { + throw new ExecutorRequestError( + typeof payload.text === "string" ? payload.text: "Tool execution failed", + null, + bodyText, + ); + } + + const structured = isRecord(payload.structured) ? payload.structured: {}; + const result = structured.result; + // Dynamic tool results already arrive in the outcome envelope; anything + // else (static tools returning raw values) is wrapped as a success. + if (isRecord(result) && typeof result.ok === "boolean") { + return result as ExecutorToolOutcome; + } + return { ok: true, data: result }; + }; + + const callByPath = ( + path: string, + input?: Record, + callOptions?: ExecutorCallOptions, + ) => invoke(path.split("."), input, callOptions); + + const makeNode = (segments: readonly string[]): unknown => + new Proxy(() => undefined, { + get: (_target, property) => { + if (segments.length === 0 && property === "$call") { + return callByPath; + } + if (typeof property !== "string") { + return undefined; + } + return makeNode([...segments, property]); + }, + apply: (_target, _thisArg, args: unknown[]) => invoke(segments, args[0], args[1] as ExecutorCallOptions | undefined), + }); + + return makeNode([]) as ExecutorClient; +} +`; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +export type GeneratedToolProxy = { + readonly source: string; + readonly toolCount: number; + readonly connectionCount: number; +}; + +export const generateToolProxySource = ( + catalog: ToolCatalogExport, + options: GenerateToolProxyOptions = {}, +): GeneratedToolProxy => { + const chunkSize = Math.max(1, options.chunkSize ?? DEFAULT_CHUNK_SIZE); + const clientName = options.clientName ?? "createExecutorClient"; + + const connections = [...catalog.connections].sort((a, b) => { + const left = `${a.integration}${a.owner}${a.connection}`; + const right = `${b.integration}${b.owner}${b.connection}`; + return left.localeCompare(right); + }); + + const usedNamespaces = new Set(); + const compiledConnections = connections.map((connection) => + compileConnection( + connection, + uniqueName( + `Executor_${sanitizeIdentifier(`${connection.integration}_${connection.owner}_${connection.connection}`)}`, + usedNamespaces, + ), + chunkSize, + ), + ); + + const root: TreeNode = { children: new Map() }; + let toolCount = 0; + for (const connection of compiledConnections) { + for (const tool of connection.tools) { + insertTool(root, tool); + toolCount += 1; + } + } + + const out: string[] = []; + out.push( + "// Generated by `executor generate`. Do not edit.\n", + "// Regenerate with: executor generate\n", + "/* eslint-disable */\n", + "\n", + ); + out.push(runtimeTemplate(clientName)); + out.push("\n"); + + for (const connection of compiledConnections) { + if (connection.declarations.length === 0) { + continue; + } + // Type-only (non-instantiated) namespace: erased at compile time, so the + // generated file stays valid under erasableSyntaxOnly and isolatedModules. + out.push(`export namespace ${connection.namespaceName} {\n`); + for (const declaration of connection.declarations) { + out.push(declaration, "\n"); + } + out.push("}\n\n"); + } + + out.push("export interface ExecutorTools "); + const treeOut: string[] = []; + emitNode(root, "", treeOut); + // The root is never callable; emitNode on an empty tree emits nothing. + out.push(treeOut.length > 0 ? treeOut.join("") : "{\n}"); + out.push("\n"); + + return { + source: out.join(""), + toolCount, + connectionCount: compiledConnections.length, + }; +}; diff --git a/packages/core/sdk/src/types.ts b/packages/core/sdk/src/types.ts index 852d409db..8d2bd5a74 100644 --- a/packages/core/sdk/src/types.ts +++ b/packages/core/sdk/src/types.ts @@ -7,7 +7,7 @@ import { Schema } from "effect"; -import { ToolAddress } from "./ids"; +import { ConnectionName, IntegrationSlug, Owner, ToolAddress } from "./ids"; // --------------------------------------------------------------------------- // ToolSchemaView — the full schema-side view of a tool, returned by @@ -28,6 +28,41 @@ export const ToolSchemaView = Schema.Struct({ }); export type ToolSchemaView = typeof ToolSchemaView.Type; +// --------------------------------------------------------------------------- +// ToolCatalogExport — the bulk schema-bearing read returned by +// `executor.tools.export(filter)`. Unlike `tools.list` (metadata-only, schemas +// deliberately projected out) this carries every tool's input/output JSON +// schema plus the connection's shared `$defs`, grouped per connection so a +// consumer (the `executor generate` typegen) can compile self-contained +// TypeScript without one `tools.schema` round trip per tool. +// --------------------------------------------------------------------------- + +export const ToolCatalogToolExport = Schema.Struct({ + address: ToolAddress, + name: Schema.String, + description: Schema.optional(Schema.String), + inputSchema: Schema.optional(Schema.Unknown), + outputSchema: Schema.optional(Schema.Unknown), + static: Schema.optional(Schema.Boolean), +}); +export type ToolCatalogToolExport = typeof ToolCatalogToolExport.Type; + +export const ToolCatalogConnectionExport = Schema.Struct({ + owner: Owner, + integration: IntegrationSlug, + connection: ConnectionName, + tools: Schema.Array(ToolCatalogToolExport), + /** Shared `$defs` referenced by this connection's tool schemas, already + * trimmed to the referenced subset. */ + definitions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}); +export type ToolCatalogConnectionExport = typeof ToolCatalogConnectionExport.Type; + +export const ToolCatalogExport = Schema.Struct({ + connections: Schema.Array(ToolCatalogConnectionExport), +}); +export type ToolCatalogExport = typeof ToolCatalogExport.Type; + // --------------------------------------------------------------------------- // Integration detection — optional capability on `PluginSpec.detect`. When a // user pastes a URL in the onboarding UI, `executor.integrations.detect(url)` diff --git a/packages/plugins/openapi/src/sdk/typegen-scale.test.ts b/packages/plugins/openapi/src/sdk/typegen-scale.test.ts new file mode 100644 index 000000000..76d9965f8 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/typegen-scale.test.ts @@ -0,0 +1,213 @@ +// --------------------------------------------------------------------------- +// `executor generate` at catalog scale: 10,000 tools from one OpenAPI spec. +// +// The typed-proxy pipeline must hold up on instances with five-digit tool +// counts, end to end through the REAL ingestion path (addSpec compiles the +// spec, a connection persists the tool rows) rather than hand-built fixtures: +// - `tools.export` returns every tool in one read, +// - `generateToolProxySource` emits the file without blowing time or memory +// (chunked schema compilation: one whole-catalog compiler pass is +// super-linear and takes 30s+ at this size), +// - the generated source is valid strict TypeScript, verified with the real +// compiler, and spot-checked types resolve to the right shapes. +// +// Budgets are deliberately loose (CI machines vary) but tight enough to catch +// a regression to per-tool or whole-catalog compilation. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as ts from "typescript"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + createExecutor, + generateToolProxySource, +} from "@executor-js/sdk"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; + +import { openApiPlugin } from "./plugin"; + +const TOOL_COUNT = 10_000; + +// Build a 10k-operation spec with realistic shape variety: per-operation +// parameter schemas, shared component refs, and enough distinct field names +// that deduplication cannot collapse the work. +const buildScaleSpec = (toolCount: number): string => { + const paths: Record = {}; + for (let index = 0; index < toolCount; index += 1) { + paths[`/resources${index % 100}/r${index}`] = { + get: { + operationId: `res.op${index}`, + summary: `Operation ${index}`, + parameters: [ + { name: "id", in: "query", required: true, schema: { type: "string" } }, + { name: `filter${index % 250}`, in: "query", schema: { type: "string" } }, + { name: "limit", in: "query", schema: { type: "integer" } }, + ], + responses: { + "200": { + description: "ok", + content: { + "application/json": { + schema: { + type: "object", + properties: { + item: { $ref: "#/components/schemas/Item" }, + page: { $ref: "#/components/schemas/Page" }, + total: { type: "integer" }, + }, + }, + }, + }, + }, + }, + }, + }; + } + // @effect-diagnostics-next-line preferSchemaOverJson:off + return JSON.stringify({ + openapi: "3.0.0", + info: { title: "Scale", version: "1.0.0" }, + servers: [{ url: "https://scale.example.test" }], + security: [{ apiKey: [] }], + paths, + components: { + securitySchemes: { + apiKey: { type: "apiKey", in: "header", name: "x-api-key" }, + }, + schemas: { + Item: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["id"], + }, + Page: { + type: "object", + properties: { cursor: { type: "string" }, hasMore: { type: "boolean" } }, + }, + }, + }, + }); +}; + +const typecheck = (source: string, extraSource: string): readonly string[] => { + const fileName = "generated.ts"; + const fullSource = `${source}\n${extraSource}`; + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], + }; + const host = ts.createCompilerHost(options); + const originalGetSourceFile = host.getSourceFile.bind(host); + const originalReadFile = host.readFile.bind(host); + const originalFileExists = host.fileExists.bind(host); + host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => + candidate === fileName + ? ts.createSourceFile(candidate, fullSource, languageVersion, true) + : originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile); + host.readFile = (candidate) => + candidate === fileName ? fullSource : originalReadFile(candidate); + host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate); + + const program = ts.createProgram([fileName], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +}; + +describe("typed proxy generation at 10k-tool scale", () => { + it.effect( + "exports, generates, and typechecks a 10,000-tool catalog", + () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), + memoryCredentialsPlugin(), + ] as const, + }), + ); + + const added = yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: buildScaleSpec(TOOL_COUNT) }, + slug: "scale", + }); + expect(added.toolCount).toBe(TOOL_COUNT); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("scale"), + template: AuthTemplateSlug.make("apikey-0"), + value: "token", + }); + + const exportStart = performance.now(); + const exported = yield* executor.tools.export({ + integration: IntegrationSlug.make("scale"), + }); + const exportMs = performance.now() - exportStart; + const exportedCount = exported.connections.reduce( + (sum, connection) => sum + connection.tools.length, + 0, + ); + expect(exportedCount).toBe(TOOL_COUNT); + + const generateStart = performance.now(); + const generated = generateToolProxySource(exported); + const generateMs = performance.now() - generateStart; + expect(generated.toolCount).toBe(TOOL_COUNT); + + // Regression tripwires, not benchmarks: whole-catalog single-pass + // compilation measured 30s+ here and per-tool passes are far worse; + // the chunked path runs in well under a second. 15s/30s absorb slow + // CI machines while still failing on a complexity regression. + expect(exportMs).toBeLessThan(15_000); + expect(generateMs).toBeLessThan(30_000); + + // Every tool surfaced in the generated interface exactly once. The + // plugin derives paths from the URL (`/resources77/r7777` → + // `resources77.resOp7777`), so count the leaf entries. + const opMatches = generated.source.match(/resOp\d+: ExecutorToolFn Date: Wed, 1 Jul 2026 22:40:51 -0700 Subject: [PATCH 2/2] Make executor generate OpenAPI-first, backed by REST tool invocation The primary artifact is now an OpenAPI 3.1 document (executor.openapi.json by default) describing every visible tool as one REST operation, so people feed it to whatever client generator they already use (openapi-typescript, openapi-generator, Kiota, ...) instead of being locked to our emitted client. --format typescript keeps the self-contained TypeScript client; --format both writes both. The document's operations are real endpoints: - POST /tools/invoke/{path} invokes one tool directly over HTTP through the execution engine: 404 for unknown tools, uniform ok/error envelope, and approval-gated calls return code execution_paused with an executionId and resumePath (autoApprove=true opts the caller in as approver). - GET /tools/export/openapi serves the live document, servers[0] derived from the request so generated clients dial the right base. The generated TypeScript client now calls the invoke endpoint too (instead of posting execution code), so both artifact flavors speak the same wire surface. Scale requirement updated to its real shape: 10,000+ tools accumulated across MANY specs, not one giant spec. The scale test ingests real service specs from @executor-js/emulate emulators (github, stripe) by URL plus a synthetic fleet topping the catalog past 10k, then proves the combined export, OpenAPI generation, and TypeScript generation hold up, and that the real openapi-typescript generator accepts the document with all 10k+ operations present. New integration coverage in apps/local runs the invoke + export endpoints over real HTTP handlers end to end (spec registered, connection created, upstream echo server dialed with the rendered credential). --- .changeset/typed-api-proxy-generate.md | 23 +- apps/cli/src/main.ts | 106 ++++-- apps/docs/local/cli.mdx | 32 +- apps/local/src/tools-invoke.test.ts | 318 ++++++++++++++++++ bun.lock | 44 ++- packages/core/api/src/handlers/tools.ts | 177 +++++++++- packages/core/api/src/tools/api.ts | 44 +++ packages/core/sdk/src/index.ts | 5 + packages/core/sdk/src/specgen.test.ts | 181 ++++++++++ packages/core/sdk/src/specgen.ts | 251 ++++++++++++++ packages/core/sdk/src/typegen.test.ts | 28 +- packages/core/sdk/src/typegen.ts | 75 ++--- packages/plugins/openapi/package.json | 2 + .../openapi/src/sdk/typegen-scale.test.ts | 203 ++++++++--- 14 files changed, 1326 insertions(+), 163 deletions(-) create mode 100644 apps/local/src/tools-invoke.test.ts create mode 100644 packages/core/sdk/src/specgen.test.ts create mode 100644 packages/core/sdk/src/specgen.ts diff --git a/.changeset/typed-api-proxy-generate.md b/.changeset/typed-api-proxy-generate.md index 6529dec2a..c25465015 100644 --- a/.changeset/typed-api-proxy-generate.md +++ b/.changeset/typed-api-proxy-generate.md @@ -3,14 +3,17 @@ "executor": minor --- -Add `executor generate`: emit a typed TypeScript client for an instance's tool catalog. +Add `executor generate`: export the tool catalog as an OpenAPI document or a typed TypeScript client, backed by direct REST tool invocation. -Running `executor generate` against a server writes a single self-contained -file (default `executor.gen.ts`) with a dependency-free runtime client and -full input/output types for every visible tool, so -`client.github.org.main.issues.create({ title })` is typed end to end and -calls go through the server's execution endpoint (auth, policies, and -approvals included). New `GET /tools/export` endpoint and -`executor.tools.export()` SDK surface return the whole schema-bearing catalog -in one read; generation compiles schemas in chunks and stays fast at -10,000-tool scale. +`executor generate` writes an OpenAPI 3.1 document (default +`executor.openapi.json`) describing every visible tool as a REST operation, +so any OpenAPI client generator (openapi-typescript, openapi-generator, +Kiota, ...) produces a fully typed client for your catalog. `--format +typescript` emits a ready-made self-contained TypeScript client instead (or +`both`). The document's operations are real: new `POST +/tools/invoke/{path}` invokes one tool directly over HTTP (404 for unknown +tools, `execution_paused` with resume coordinates for approval-gated calls), +`GET /tools/export/openapi` serves the live document, and `GET /tools/export` +plus `executor.tools.export()` return the whole schema-bearing catalog in one +read. Generation compiles schemas in chunks and stays fast past 10,000 tools +across many integrations. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index a0f37b43a..70d41c219 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -79,7 +79,7 @@ import { type ExecutorServerConnection, type ExecutorServerConnectionInput, } from "@executor-js/sdk/shared"; -import { generateToolProxySource } from "@executor-js/sdk/core"; +import { generateOpenApiSpec, generateToolProxySource } from "@executor-js/sdk/core"; import { decodeAccessTokenClaims, discoverCliLogin, @@ -2714,16 +2714,29 @@ const mcpCommand = Command.make( ).pipe(Command.withDescription("Start an MCP server over stdio")); // --------------------------------------------------------------------------- -// Generate — emit a typed TypeScript client for the instance's tool catalog +// Generate — export the instance's tool catalog for typed clients. +// Default artifact: an OpenAPI 3.1 document (plug it into any client +// generator). Optional: a ready-made self-contained TypeScript client. // --------------------------------------------------------------------------- +const DEFAULT_GENERATE_OPENAPI_OUTPUT = "executor.openapi.json"; +const DEFAULT_GENERATE_TYPESCRIPT_OUTPUT = "executor.gen.ts"; + const generateCommand = Command.make( "generate", { + format: Options.choice("format", ["openapi", "typescript", "both"] as const).pipe( + Options.withDefault("openapi"), + Options.withDescription( + "What to emit: an OpenAPI 3.1 document (default; feed it to any client generator), a self-contained TypeScript client, or both.", + ), + ), output: Options.string("output").pipe( Options.withAlias("o"), - Options.withDefault("executor.gen.ts"), - Options.withDescription("Path of the generated TypeScript file."), + Options.optional, + Options.withDescription( + `Output path. Defaults to ${DEFAULT_GENERATE_OPENAPI_OUTPUT} / ${DEFAULT_GENERATE_TYPESCRIPT_OUTPUT} by format; with --format both this names the OpenAPI file and the TypeScript file keeps its default.`, + ), ), integration: Options.string("integration").pipe( Options.optional, @@ -2743,7 +2756,7 @@ const generateCommand = Command.make( server: serverProfile, scope, }, - ({ output, integration, connection, includeStatic, baseUrl, server, scope }) => + ({ format, output, integration, connection, includeStatic, baseUrl, server, scope }) => Effect.gen(function* () { applyScope(scope); const target = serverTargetFromOptions({ baseUrl, server }); @@ -2774,15 +2787,8 @@ const generateCommand = Command.make( .filter((entry) => entry.tools.length > 0), }; - const generated = yield* Effect.try({ - try: () => generateToolProxySource(filtered), - catch: (cause) => - cause instanceof Error - ? cause - : new Error(`Failed to generate TypeScript source: ${String(cause)}`), - }); - - if (generated.toolCount === 0) { + const totalTools = filtered.connections.reduce((sum, entry) => sum + entry.tools.length, 0); + if (totalTools === 0) { return yield* Effect.fail( new Error( [ @@ -2796,20 +2802,70 @@ const generateCommand = Command.make( const fs = yield* FileSystem.FileSystem; const path = yield* PlatformPath.Path; - const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output); - yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }); - yield* fs.writeFileString(outputPath, generated.source); + const resolveOutput = (candidate: string): string => + path.isAbsolute(candidate) ? candidate : path.join(process.cwd(), candidate); + const writeArtifact = (candidate: string, contents: string) => + Effect.gen(function* () { + const outputPath = resolveOutput(candidate); + yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }); + yield* fs.writeFileString(outputPath, contents); + return outputPath; + }); + const requestedOutput = Option.getOrUndefined(output); + + if (format === "openapi" || format === "both") { + const generated = yield* Effect.try({ + try: () => generateOpenApiSpec(filtered, { serverUrl: serverConnection.apiBaseUrl }), + catch: (cause) => + cause instanceof Error + ? cause + : new Error(`Failed to generate the OpenAPI document: ${String(cause)}`), + }); + const outputPath = yield* writeArtifact( + requestedOutput ?? DEFAULT_GENERATE_OPENAPI_OUTPUT, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify(generated.document, null, 2)}\n`, + ); + console.log( + `Generated ${outputPath} (OpenAPI 3.1, ${generated.toolCount} tools, ${generated.connectionCount} connections).`, + ); + console.log(""); + console.log("Feed it to your client generator, e.g.:"); + console.log(` npx openapi-typescript ${path.basename(outputPath)} -o executor-api.ts`); + console.log( + `Live copy: ${serverConnection.apiBaseUrl}/tools/export/openapi (bearer auth).`, + ); + } - console.log( - `Generated ${outputPath} (${generated.toolCount} tools, ${generated.connectionCount} connections).`, - ); - console.log(""); - console.log("Use it:"); - console.log(` import { createExecutorClient } from "./${path.basename(output, ".ts")}";`); - console.log(` const executor = createExecutorClient();`); + if (format === "typescript" || format === "both") { + const generated = yield* Effect.try({ + try: () => generateToolProxySource(filtered), + catch: (cause) => + cause instanceof Error + ? cause + : new Error(`Failed to generate TypeScript source: ${String(cause)}`), + }); + const tsOutput = + format === "typescript" + ? (requestedOutput ?? DEFAULT_GENERATE_TYPESCRIPT_OUTPUT) + : DEFAULT_GENERATE_TYPESCRIPT_OUTPUT; + const outputPath = yield* writeArtifact(tsOutput, generated.source); + if (format === "both") console.log(""); + console.log( + `Generated ${outputPath} (TypeScript client, ${generated.toolCount} tools, ${generated.connectionCount} connections).`, + ); + console.log(""); + console.log("Use it:"); + console.log( + ` import { createExecutorClient } from "./${path.basename(tsOutput, ".ts")}";`, + ); + console.log(` const executor = createExecutorClient();`); + } }).pipe(Effect.mapError(toError)), ).pipe( - Command.withDescription("Generate a typed TypeScript client for this instance's tool catalog"), + Command.withDescription( + "Export this instance's tool catalog as an OpenAPI document (default) or a typed TypeScript client", + ), ); // --------------------------------------------------------------------------- diff --git a/apps/docs/local/cli.mdx b/apps/docs/local/cli.mdx index c4435ce36..6a30d89ee 100644 --- a/apps/docs/local/cli.mdx +++ b/apps/docs/local/cli.mdx @@ -112,16 +112,32 @@ executor resume --execution-id exec_123 ## Generate a typed client -`executor generate` writes a single self-contained TypeScript file with full -input/output types for every tool in your catalog, plus a dependency-free -runtime client. Drop it into any TypeScript project and every call is typed -end to end, while still executing through Executor (auth, policies, and -approvals included): +`executor generate` exports your tool catalog as an OpenAPI 3.1 document: +one REST operation per tool, with full input/output schemas. Feed it to +whatever client generator you already use (openapi-typescript, +openapi-generator, Kiota, ...) and every call is typed end to end while still +executing through Executor (auth, policies, and approvals included): ```bash -executor generate # writes executor.gen.ts -executor generate -o src/executor.ts # custom output path -executor generate --integration github # only one integration +executor generate # writes executor.openapi.json +npx openapi-typescript executor.openapi.json -o executor-api.ts +``` + +The same document is served live at `GET /api/tools/export/openapi` (bearer +auth), so generators that take a URL can point straight at the instance. The +operations it describes are real endpoints: `POST /api/tools/invoke/{tool +path}` runs the tool and returns `{ ok: true, data }` or `{ ok: false, error }`; +approval-gated calls return `error.code: "execution_paused"` with resume +coordinates. + +Prefer a ready-made client instead of running a generator? `--format +typescript` writes a single self-contained TypeScript file with a +dependency-free runtime client: + +```bash +executor generate --format typescript # writes executor.gen.ts +executor generate --format both # both artifacts +executor generate --integration github # only one integration ``` ```ts diff --git a/apps/local/src/tools-invoke.test.ts b/apps/local/src/tools-invoke.test.ts new file mode 100644 index 000000000..002305223 --- /dev/null +++ b/apps/local/src/tools-invoke.test.ts @@ -0,0 +1,318 @@ +// --------------------------------------------------------------------------- +// REST tool invocation + OpenAPI export: the wire surface behind +// `executor generate`. +// +// Full loop over real HTTP handlers (no mocked executor): an OpenAPI spec is +// registered, a connection created, then tools are invoked through +// POST /api/tools/invoke/{path}, exactly what a client generated from the +// exported OpenAPI document would do: +// - a successful call returns the ok envelope with the upstream's data, +// - an unknown path answers 404 (not a buried execution error), +// - GET /api/tools/export/openapi serves a document whose paths match the +// invokable tools and whose servers[0] points back at this API. +// --------------------------------------------------------------------------- + +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Exit, Layer, Schema, Scope } from "effect"; +import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; + +import { addGroup, observabilityMiddleware } from "@executor-js/api"; +import { + CoreHandlers, + ExecutionEngineService, + ExecutorService, + collectTables, +} from "@executor-js/api/server"; +import { createExecutionEngine } from "@executor-js/execution"; +import { openApiPlugin } from "@executor-js/plugin-openapi"; +import { + OpenApiExtensionService, + OpenApiGroup, + OpenApiHandlers, +} from "@executor-js/plugin-openapi/api"; +import { + makeOpenApiHttpApiTestSourceConfig, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + Subject, + Tenant, + createExecutor, +} from "@executor-js/sdk"; +import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; + +import { ErrorCaptureLive } from "./observability"; +import { createSqliteFumaDb } from "./db/sqlite-fumadb"; + +const TEST_BASE_URL = "http://local.test"; +const API_KEY_TEMPLATE = "apiKey"; + +// A tiny upstream: one echo endpoint, so a full invoke round trip can be +// asserted on real data. +const EchoResponse = Schema.Struct({ + echoed: Schema.String, + apiKey: Schema.optional(Schema.String), +}); + +const EchoGroup = HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.post("echo", "/echo", { + payload: Schema.Struct({ message: Schema.String }), + success: EchoResponse, + }), +); +const UpstreamApi = HttpApi.make("invokeUpstream").add(EchoGroup); + +const UpstreamLive = HttpApiBuilder.group(UpstreamApi, "default", (handlers) => + handlers.handle("echo", ({ payload }) => + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return EchoResponse.make({ + echoed: payload.message, + apiKey: req.headers["x-api-key"], + }); + }), + ), +); + +const TestApi = addGroup(OpenApiGroup); + +interface Harness { + readonly fetch: typeof globalThis.fetch; + readonly integration: string; + readonly dispose: () => Promise; +} + +const startHarness = async (tmpDir: string): Promise => { + const plugins = [ + openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), + memoryCredentialsPlugin(), + ] as const; + const sqlite = await createSqliteFumaDb({ + tables: collectTables(), + namespace: "executor_local_tools_invoke_test", + path: join(tmpDir, "data.db"), + }); + + const executor = await Effect.runPromise( + createExecutor({ + tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), + subject: Subject.make("local"), + db: sqlite.db, + plugins, + onElicitation: "accept-all", + }), + ); + + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }); + + // Real upstream server the registered spec's tools dial. Its scope stays + // open for the harness lifetime (closing it would shut the server down + // before any tool call reaches it) and is released in dispose. + const upstreamScope = Effect.runSync(Scope.make()); + const upstream = await Effect.runPromise( + serveOpenApiHttpApiTestServer({ api: UpstreamApi, handlersLayer: UpstreamLive }).pipe( + Scope.provide(upstreamScope), + Effect.orDie, + ), + ); + + const integration = `invoke_${randomBytes(4).toString("hex")}`; + await Effect.runPromise( + executor.openapi + .addSpec({ + ...makeOpenApiHttpApiTestSourceConfig(UpstreamApi, { + slug: integration, + baseUrl: upstream.baseUrl, + authenticationTemplate: [ + { + slug: AuthTemplateSlug.make(API_KEY_TEMPLATE), + type: "apiKey" as const, + headers: { "x-api-key": [{ type: "variable" as const, name: "token" }] }, + }, + ], + }), + }) + .pipe(Effect.orDie), + ); + await Effect.runPromise( + executor.connections + .create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(integration), + template: AuthTemplateSlug.make(API_KEY_TEMPLATE), + value: "secret-key", + }) + .pipe(Effect.orDie), + ); + + const TestObservability = observabilityMiddleware(TestApi); + const TestApiBase = HttpApiBuilder.layer(TestApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(OpenApiHandlers), + Layer.provide(TestObservability), + Layer.provide(ErrorCaptureLive), + ); + + const { handler: webHandler, dispose: disposeHandler } = HttpRouter.toWebHandler( + TestApiBase.pipe( + Layer.provideMerge(Layer.succeed(OpenApiExtensionService)(executor.openapi)), + Layer.provideMerge(Layer.succeed(ExecutorService)(executor)), + Layer.provideMerge(Layer.succeed(ExecutionEngineService)(engine)), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ), + ); + + return { + fetch: ((input: RequestInfo | URL, init?: RequestInit) => + webHandler( + input instanceof Request ? input : new Request(input, init), + )) as typeof globalThis.fetch, + integration, + dispose: async () => { + await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => disposeHandler()))); + await Effect.runPromise(Effect.ignore(executor.close())); + await Effect.runPromise(Scope.close(upstreamScope, Exit.void)); + await sqlite.close(); + }, + }; +}; + +let tmpDir: string; +let harness: Harness; + +beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "executor-local-tools-invoke-")); + harness = await startHarness(tmpDir); +}); + +afterAll(async () => { + await harness.dispose(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +const postInvoke = (path: string, body: unknown, options?: { autoApprove?: boolean }) => + Effect.tryPromise({ + try: async () => { + const query = options?.autoApprove ? "?autoApprove=true" : ""; + const response = await harness.fetch( + `${TEST_BASE_URL}/tools/invoke/${encodeURIComponent(path)}${query}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: JSON.stringify(body), + }, + ); + return { status: response.status, body: (await response.json()) as unknown }; + }, + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-error-constructor, executor/no-unknown-error-message -- test boundary: normalize in-process fetch rejections + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + +describe("REST tool invocation", () => { + it.effect("invokes a tool end to end and returns the ok envelope", () => + Effect.gen(function* () { + // OpenAPI-plugin tools take carrier-shaped input: the request body + // rides under `body` (matching the tool's exported input schema). + // autoApprove: POST tools default to approval-gated; the caller here + // is the approver. + const { status, body } = yield* postInvoke( + `${harness.integration}.org.main.default.echo`, + { body: { message: "hello" } }, + { autoApprove: true }, + ); + expect(status).toBe(200); + // Real upstream data plus the rendered credential prove the whole + // executor path (auth template included) ran. + expect(body).toMatchObject({ + ok: true, + data: { echoed: "hello", apiKey: "secret-key" }, + }); + }), + ); + + it.effect("pauses approval-gated calls with resume coordinates", () => + Effect.gen(function* () { + const { status, body } = yield* postInvoke(`${harness.integration}.org.main.default.echo`, { + body: { message: "needs approval" }, + }); + expect(status).toBe(200); + expect(body).toMatchObject({ + ok: false, + error: { code: "execution_paused" }, + }); + const error = (body as { error: { executionId?: string; resumePath?: string } }).error; + expect(error.executionId).toMatch(/^exec_/); + expect(error.resumePath).toBe(`/executions/${encodeURIComponent(error.executionId!)}/resume`); + }), + ); + + it.effect("answers 404 for an unknown tool path", () => + Effect.gen(function* () { + const { status } = yield* postInvoke(`${harness.integration}.org.main.default.nope`, {}); + expect(status).toBe(404); + }), + ); + + it.effect("rejects a non-object body with a typed error", () => + Effect.gen(function* () { + const { status, body } = yield* postInvoke( + `${harness.integration}.org.main.default.echo`, + [1, 2, 3], + ); + expect(status).toBe(200); + expect(body).toMatchObject({ ok: false, error: { code: "invalid_input" } }); + }), + ); +}); + +describe("OpenAPI export endpoint", () => { + it.effect("serves a document whose operations match the invokable tools", () => + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => + harness.fetch(`${TEST_BASE_URL}/tools/export/openapi`, { + // The direct web-handler harness carries no Host header; supply + // the forwarded pair a fronting proxy would send. + headers: { "x-forwarded-host": "local.test", "x-forwarded-proto": "http" }, + }), + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-error-constructor, executor/no-unknown-error-message -- test boundary: normalize in-process fetch rejections + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + expect(response.status).toBe(200); + const document = (yield* Effect.tryPromise({ + try: () => response.json() as Promise>, + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-error-constructor, executor/no-unknown-error-message -- test boundary: normalize in-process fetch rejections + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + })) as { + openapi: string; + servers: ReadonlyArray<{ url: string }>; + paths: Record; + }; + + expect(document.openapi).toBe("3.1.0"); + // servers[0] points back at this instance's public API base, derived + // from the request (`/api` is what CLI/proxy clients dial; the host + // shell strips it before routes see it). + expect(document.servers[0]!.url).toBe(`${TEST_BASE_URL}/api`); + expect( + document.paths[`/tools/invoke/${harness.integration}.org.main.default.echo`], + ).toBeDefined(); + }), + ); +}); diff --git a/bun.lock b/bun.lock index 4f2b16d50..9d9d1d837 100644 --- a/bun.lock +++ b/bun.lock @@ -1067,12 +1067,14 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/emulate": "^0.9.0", "@executor-js/react": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", "effect": "catalog:", + "openapi-typescript": "^7.13.0", "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:", @@ -2724,6 +2726,12 @@ "@react-grab/cli": ["@react-grab/cli@0.1.32", "", { "dependencies": { "@antfu/ni": "^0.23.0", "commander": "^14.0.0", "ignore": "^7.0.5", "jsonc-parser": "^3.3.1", "ora": "^8.2.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "smol-toml": "^1.6.0" }, "bin": { "react-grab": "dist/cli.js" } }, "sha512-TI4SHATLH2yM1DMRXgH3dt/8b3Rj51BplDOqOQiHQKAMOuKVAR9WE2WGWJRT3LwFpl8BXR9ytAM9vrGDrB7QGw=="], + "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], + + "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], + + "@redocly/openapi-core": ["@redocly/openapi-core@1.34.17", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.2.0", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], @@ -3452,6 +3460,8 @@ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -3524,6 +3534,8 @@ "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], + "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -4202,6 +4214,8 @@ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -4348,6 +4362,8 @@ "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], + "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -4808,6 +4824,8 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="], + "openid-client": ["openid-client@6.8.4", "", { "dependencies": { "jose": "^6.2.2", "oauth4webapi": "^3.8.5" } }, "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw=="], "opentracing": ["opentracing@0.11.1", "", {}, "sha512-I9brEVUSl+5n/fKm6bq64qndwqbZwIciW3HsV3syl8jzgmUkweAHto+IOeSqwsG4BI3FamDqJ7CnQexc94XVRA=="], @@ -4862,7 +4880,7 @@ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], @@ -4950,6 +4968,8 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -5662,6 +5682,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], + "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], @@ -5788,6 +5810,8 @@ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], @@ -6264,6 +6288,10 @@ "@react-grab/cli/ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + "@redocly/openapi-core/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -6426,6 +6454,8 @@ "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -6584,6 +6614,10 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "openapi-typescript/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "openapi-typescript/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "openid-client/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -6594,6 +6628,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], @@ -7054,6 +7090,8 @@ "@react-grab/cli/ora/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "@sentry/electron/@sentry/browser/@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA=="], "@sentry/electron/@sentry/browser/@sentry-internal/feedback": ["@sentry-internal/feedback@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w=="], @@ -7416,6 +7454,8 @@ "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], @@ -7674,6 +7714,8 @@ "@react-grab/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@redocly/openapi-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@slack/web-api/axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], diff --git a/packages/core/api/src/handlers/tools.ts b/packages/core/api/src/handlers/tools.ts index 486747b8f..4abbe34fc 100644 --- a/packages/core/api/src/handlers/tools.ts +++ b/packages/core/api/src/handlers/tools.ts @@ -1,10 +1,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpServerRequest } from "effect/unstable/http"; import { Effect } from "effect"; -import { ToolNotFoundError, type Tool } from "@executor-js/sdk"; +import { generateOpenApiSpec, ToolAddress, ToolNotFoundError, type Tool } from "@executor-js/sdk"; +import { formatExecuteResult } from "@executor-js/execution"; import { ExecutorApi } from "../api"; -import { ExecutorService } from "../services"; -import { capture } from "@executor-js/api"; +import { ExecutionEngineService, ExecutorService } from "../services"; +import { capture, captureEngineError } from "@executor-js/api"; const toMetadata = (t: Tool) => ({ address: t.address, @@ -64,5 +66,174 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler }); }), ), + ) + .handle("openapi", ({ query }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const request = yield* HttpServerRequest.HttpServerRequest; + const catalog = yield* executor.tools.export({ + integration: query.integration, + owner: query.owner, + connection: query.connection, + query: query.query, + includeAnnotations: query.includeAnnotations === "true", + includeBlocked: query.includeBlocked === "true", + }); + const serverUrl = serverUrlFromRequest(request); + return generateOpenApiSpec(catalog, { + ...(serverUrl !== undefined ? { serverUrl } : {}), + }).document; + }), + ), + ) + .handle("invoke", ({ params, query, payload }) => + capture( + Effect.gen(function* () { + const path = params.path; + const address = ToolAddress.make(path.startsWith("tools.") ? path : `tools.${path}`); + if (!TOOL_PATH_PATTERN.test(path)) { + return yield* new ToolNotFoundError({ address }); + } + if (payload !== undefined && !isJsonObject(payload)) { + return failureOutcome({ + code: "invalid_input", + message: "Tool input must be a JSON object.", + }); + } + + // Run through the execution engine (not executor.execute directly) + // so approval-gated calls pause with a resumable executionId, same + // as every other host surface. + const engine = yield* ExecutionEngineService; + const outcome = yield* captureEngineError( + engine.executeWithPause(buildInvokeCode(path, payload ?? {}), { + autoApprove: query.autoApprove === "true", + }), + ); + + if (outcome.status === "paused") { + return failureOutcome({ + code: "execution_paused", + message: "This call requires approval. Resume the paused execution to complete it.", + executionId: outcome.execution.id, + resumePath: `/executions/${encodeURIComponent(outcome.execution.id)}/resume`, + }); + } + + const formatted = formatExecuteResult(outcome.result); + const result = isJsonObject(formatted.structured) + ? formatted.structured.result + : undefined; + if (isToolNotFoundSentinel(result) || isToolNotFoundOutcome(result)) { + return yield* new ToolNotFoundError({ address }); + } + if (formatted.isError) { + return failureOutcome({ + code: "execution_failed", + message: formatted.text, + }); + } + // Dynamic tools already return the ok/error envelope; static tools + // may return raw values, wrapped here so the wire shape is uniform. + if (isJsonObject(result) && result.ok === true) { + return { + ok: true as const, + ...("data" in result ? { data: result.data } : {}), + ...("http" in result ? { http: result.http } : {}), + }; + } + if (isJsonObject(result) && result.ok === false) { + return failureOutcome( + isJsonObject(result.error) ? result.error : { value: result.error }, + ); + } + return { ok: true as const, data: result }; + }), + ), ), ); + +// --------------------------------------------------------------------------- +// tools.invoke plumbing +// --------------------------------------------------------------------------- + +type InvokeOutcomeShape = + | { readonly ok: true; readonly data?: unknown; readonly http?: unknown } + | { readonly ok: false; readonly error: unknown }; + +// Dotted tool path: letters/digits/._- segments (mirrors the CLI's +// tool-path validation; blocks anything that could escape the code string). +const TOOL_PATH_PATTERN = /^[A-Za-z0-9._-]+$/; + +const TOOL_NOT_FOUND_SENTINEL = "__executor_tool_not_found__"; + +const isJsonObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const failureOutcome = (error: Record): InvokeOutcomeShape => ({ + ok: false, + error, +}); + +const isToolNotFoundSentinel = (value: unknown): boolean => + isJsonObject(value) && value[TOOL_NOT_FOUND_SENTINEL] === true; + +/** The sandbox `tools` proxy makes every path callable and reports a missing + * tool as an `ok: false` outcome with `code: "tool_not_found"`; surface that + * as HTTP 404 (the sentinel above only fires when the proxy is bypassed). */ +const isToolNotFoundOutcome = (value: unknown): boolean => + isJsonObject(value) && + value.ok === false && + isJsonObject(value.error) && + value.error.code === "tool_not_found"; + +/** Same invocation shape the CLI's `executor call` generates: resolve the + * path off the sandbox `tools` proxy and call it. A missing tool returns a + * sentinel (not a throw) so the handler can answer 404 instead of burying + * "not found" inside an execution error string. */ +const buildInvokeCode = (toolPath: string, args: unknown): string => { + const access = toolPath + .split(".") + .map((segment) => `[${JSON.stringify(segment)}]`) + .join(""); + return [ + `const __args = ${JSON.stringify(args)};`, + `const __target = tools${access};`, + `if (typeof __target !== "function") {`, + ` return { ${JSON.stringify(TOOL_NOT_FOUND_SENTINEL)}: true };`, + `}`, + `return await __target(__args);`, + ].join("\n"); +}; + +/** Derive the spec's `servers[0].url` from the incoming request: the API + * base is everything before the `/tools/export/openapi` suffix. + * `request.url` may be path-only depending on the host adapter; rebuild the + * origin from Forwarded/Host headers when it is. */ +const serverUrlFromRequest = (request: HttpServerRequest.HttpServerRequest): string | undefined => { + const marker = "/tools/export/openapi"; + // originalUrl keeps a router-level mount prefix (self-host's + // `router.prefixed("/api")`); hosts whose outer shell strips `/api` before + // dispatch (local) leave an empty prefix here. Either way the public API + // base is `origin + /api` (what the CLI's apiBaseUrl dials), so an empty + // prefix falls back to `/api`. + const url = request.originalUrl; + const index = url.indexOf(marker); + if (index < 0) return undefined; + const rawPrefix = url.slice(0, index); + if (/^https?:\/\//.test(rawPrefix)) { + // Absolute URL: split origin from any path prefix; an empty path means + // the shell stripped `/api` before dispatch. + const slash = rawPrefix.indexOf("/", rawPrefix.indexOf("//") + 2); + const origin = slash < 0 ? rawPrefix : rawPrefix.slice(0, slash); + const pathPrefix = slash < 0 ? "" : rawPrefix.slice(slash); + return `${origin}${pathPrefix.length > 0 ? pathPrefix : "/api"}`; + } + const prefix = rawPrefix.length > 0 ? rawPrefix : "/api"; + const host = request.headers["x-forwarded-host"] ?? request.headers.host; + if (typeof host !== "string" || host.length === 0) return undefined; + const proto = request.headers["x-forwarded-proto"]; + const scheme = typeof proto === "string" && proto.length > 0 ? proto : "http"; + return `${scheme}://${host}${prefix}`; +}; diff --git a/packages/core/api/src/tools/api.ts b/packages/core/api/src/tools/api.ts index 5f5b6cf93..4d732b5db 100644 --- a/packages/core/api/src/tools/api.ts +++ b/packages/core/api/src/tools/api.ts @@ -59,6 +59,29 @@ const SchemaQuery = Schema.Struct({ address: ToolAddress, }); +// `tools.invoke` request/response. The response is the same ok/error outcome +// envelope tools return in the sandbox; a paused (approval-gated) execution +// surfaces as `ok: false` with `code: "execution_paused"` plus resume +// coordinates, so REST callers see exactly one response shape. +const InvokeToolParams = { path: Schema.String }; + +const InvokeQuery = Schema.Struct({ + // Query params arrive as strings; "true" approves approval-gated tools. + autoApprove: Schema.optional(Schema.String), +}); + +const InvokeOutcome = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + data: Schema.optional(Schema.Unknown), + http: Schema.optional(Schema.Unknown), + }), + Schema.Struct({ + ok: Schema.Literal(false), + error: Schema.Unknown, + }), +]); + // --------------------------------------------------------------------------- // Error schemas with HTTP status annotations // --------------------------------------------------------------------------- @@ -94,4 +117,25 @@ export const ToolsApi = HttpApiGroup.make("tools") success: ToolCatalogExport, error: InternalError, }), + ) + .add( + // The catalog as an OpenAPI 3.1 document: one POST operation per tool, + // pointing at `invoke` below. Feed it to any OpenAPI client generator. + HttpApiEndpoint.get("openapi", "/tools/export/openapi", { + query: ListToolsQuery, + success: Schema.Unknown, + error: InternalError, + }), + ) + .add( + // Direct REST invocation of one tool by its dotted path (the operations + // the generated OpenAPI document describes). Bodies are the tool's input + // object; the response is the uniform outcome envelope. + HttpApiEndpoint.post("invoke", "/tools/invoke/:path", { + params: InvokeToolParams, + query: InvokeQuery, + payload: Schema.Unknown, + success: InvokeOutcome, + error: [InternalError, ToolNotFound], + }), ); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 26a17c5cd..ee7351b96 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -349,6 +349,11 @@ export { type GeneratedToolProxy, type GenerateToolProxyOptions, } from "./typegen"; +export { + generateOpenApiSpec, + type GeneratedOpenApiSpec, + type GenerateOpenApiSpecOptions, +} from "./specgen"; // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. export { InternalError } from "./api-errors"; diff --git a/packages/core/sdk/src/specgen.test.ts b/packages/core/sdk/src/specgen.test.ts new file mode 100644 index 000000000..998d376b0 --- /dev/null +++ b/packages/core/sdk/src/specgen.test.ts @@ -0,0 +1,181 @@ +// --------------------------------------------------------------------------- +// generateOpenApiSpec: catalog -> OpenAPI 3.1 document. +// +// The document is the interop artifact of `executor generate`: it must hold +// up under other people's tooling, so beyond shape assertions these tests +// run the real `openapi-typescript` generator over the output in the scale +// suite (packages/plugins/openapi/src/sdk/typegen-scale.test.ts). Here: +// - one POST operation per tool at /tools/invoke/{path}, request body from +// the input schema, envelope responses referencing shared components, +// - shared $defs hoisted to namespaced components.schemas with refs +// rewritten, per-connection namespaces so equal names don't collide, +// - operationIds unique and identifier-safe, static tools included, +// - schema-less tools get no requestBody and an unconstrained data shape. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; + +import { ConnectionName, IntegrationSlug, ToolAddress } from "./ids"; +import { generateOpenApiSpec } from "./specgen"; +import type { ToolCatalogExport } from "./types"; + +type ConnectionExport = ToolCatalogExport["connections"][number]; +type ToolExport = ConnectionExport["tools"][number]; + +const connectionExport = (input: { + owner: "org" | "user"; + integration: string; + connection: string; + definitions?: Record; + tools: ReadonlyArray<{ + address: string; + name: string; + description?: string; + inputSchema?: unknown; + outputSchema?: unknown; + static?: boolean; + }>; +}): ConnectionExport => ({ + owner: input.owner, + integration: IntegrationSlug.make(input.integration), + connection: ConnectionName.make(input.connection), + ...(input.definitions !== undefined ? { definitions: input.definitions } : {}), + tools: input.tools.map( + (tool): ToolExport => ({ + ...tool, + address: ToolAddress.make(tool.address), + }), + ), +}); + +const catalog = (connections: readonly ConnectionExport[]): ToolCatalogExport => ({ connections }); + +const githubConnection = connectionExport({ + owner: "org", + integration: "github", + connection: "main", + definitions: { + User: { + type: "object", + properties: { id: { type: "string" }, login: { type: "string" } }, + required: ["id"], + }, + }, + tools: [ + { + address: "tools.github.org.main.issues.create", + name: "issues.create", + description: "Create an issue\n\nLonger description body.", + inputSchema: { + type: "object", + properties: { title: { type: "string" }, assignee: { $ref: "#/$defs/User" } }, + required: ["title"], + }, + outputSchema: { + type: "object", + properties: { number: { type: "number" }, user: { $ref: "#/$defs/User" } }, + }, + }, + { + address: "tools.github.org.main.repos.get", + name: "repos.get", + }, + ], +}); + +type SpecDocument = { + openapi: string; + servers: ReadonlyArray<{ url: string }>; + paths: Record }>; + components: { schemas: Record }; +}; + +const asDocument = (document: Record): SpecDocument => + // oxlint-disable-next-line executor/no-double-cast -- test boundary: narrow the generated document to the fields these assertions read + document as unknown as SpecDocument; + +describe("generateOpenApiSpec", () => { + it("emits one POST operation per tool with namespaced component refs", () => { + const generated = generateOpenApiSpec(catalog([githubConnection]), { + serverUrl: "http://example.test:4788/api", + }); + expect(generated.toolCount).toBe(2); + const document = asDocument(generated.document); + + expect(document.openapi).toBe("3.1.0"); + expect(document.servers).toEqual([{ url: "http://example.test:4788/api" }]); + + const create = document.paths["/tools/invoke/github.org.main.issues.create"]?.post; + expect(create).toBeDefined(); + expect(create!.operationId).toBe("github_org_main_issues_create"); + expect(create!.summary).toBe("Create an issue"); + + // The input schema is inline; its $defs refs point at the namespaced + // component. + const requestBody = create!.requestBody as { + required: boolean; + content: { "application/json": { schema: { properties: { assignee: { $ref: string } } } } }; + }; + expect(requestBody.required).toBe(true); + expect(requestBody.content["application/json"].schema.properties.assignee.$ref).toBe( + "#/components/schemas/github.org.main.User", + ); + + // Shared definition hoisted once, under the connection namespace. + expect(document.components.schemas["github.org.main.User"]).toMatchObject({ + type: "object", + required: ["id"], + }); + // Envelope schemas present. + expect(document.components.schemas.ExecutorToolError).toBeDefined(); + expect(document.components.schemas.ExecutorToolHttpMeta).toBeDefined(); + + // Schema-less tool: no requestBody, still has the envelope response. + const get = document.paths["/tools/invoke/github.org.main.repos.get"]?.post; + expect(get).toBeDefined(); + expect(get!.requestBody).toBeUndefined(); + expect(get!.responses).toMatchObject({ "200": {}, "404": {} }); + }); + + it("keeps equal definition names from different connections apart", () => { + const other = connectionExport({ + owner: "user", + integration: "linear", + connection: "personal", + definitions: { + User: { type: "object", properties: { email: { type: "string" } } }, + }, + tools: [ + { + address: "tools.linear.user.personal.me", + name: "me", + outputSchema: { $ref: "#/$defs/User" }, + }, + ], + }); + + const document = asDocument(generateOpenApiSpec(catalog([githubConnection, other])).document); + expect(document.components.schemas["github.org.main.User"]).toMatchObject({ + required: ["id"], + }); + expect(document.components.schemas["linear.user.personal.User"]).toMatchObject({ + properties: { email: { type: "string" } }, + }); + }); + + it("dedupes colliding operationIds", () => { + const collisions = connectionExport({ + owner: "org", + integration: "demo", + connection: "main", + tools: [ + { address: "tools.demo.org.main.a.b", name: "a.b" }, + // Same sanitized operationId as a.b. + { address: "tools.demo.org.main.a_b", name: "a_b" }, + ], + }); + const document = asDocument(generateOpenApiSpec(catalog([collisions])).document); + const ids = Object.values(document.paths).map((entry) => entry.post.operationId); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/packages/core/sdk/src/specgen.ts b/packages/core/sdk/src/specgen.ts new file mode 100644 index 000000000..8189a3d30 --- /dev/null +++ b/packages/core/sdk/src/specgen.ts @@ -0,0 +1,251 @@ +// --------------------------------------------------------------------------- +// OpenAPI spec generation: the primary `executor generate` artifact. +// +// Turns a `ToolCatalogExport` into an OpenAPI 3.1 document describing the +// instance's tool catalog as a plain REST API: one POST operation per tool at +// `/tools/invoke/{tool path}`, request body = the tool's input schema, +// response = the ok/error outcome envelope every tool returns. The point is +// interop: the document feeds any OpenAPI client generator +// (openapi-typescript, openapi-generator, Kiota, ...), so people bring their +// own client instead of ours. +// +// Shared `$defs` are hoisted into `components.schemas` under a +// per-connection namespace (`...`), so +// a 10k-tool catalog references each shared schema once instead of inlining +// 10k copies. Tool input/output schemas stay inline in their operation: +// they are tool-specific, and inlining avoids inventing 20k component names. +// --------------------------------------------------------------------------- + +import { normalizeRefs } from "./schema-refs"; +import type { ToolCatalogConnectionExport, ToolCatalogExport } from "./types"; + +export type GenerateOpenApiSpecOptions = { + /** `servers[0].url` of the document. Point it at the instance's API base + * (origin + `/api`). Defaults to the local daemon. */ + readonly serverUrl?: string; + readonly title?: string; + readonly version?: string; +}; + +export type GeneratedOpenApiSpec = { + readonly document: Record; + readonly toolCount: number; + readonly connectionCount: number; +}; + +const DEFAULT_SERVER_URL = "http://localhost:4788/api"; + +const ADDRESS_PREFIX = "tools."; + +/** Sandbox-callable tool path: dynamic addresses drop the `tools.` proxy-root + * prefix; static addresses already are the callable path. Mirrors the MCP + * tool server's naming. */ +const addressToPath = (address: string): string => + address.startsWith(ADDRESS_PREFIX) ? address.slice(ADDRESS_PREFIX.length) : address; + +const DEFS_REF_PATTERN = /^#\/(?:\$defs|definitions)\/(.+)$/; + +/** Deep-rewrite `#/$defs/` refs to namespaced `#/components/schemas/` + * pointers. Returns the input unchanged when nothing needs rewriting. */ +const rewriteRefs = (node: unknown, namespace: string): unknown => { + if (node === null || typeof node !== "object") return node; + if (Array.isArray(node)) { + let changed = false; + const out = node.map((item) => { + const next = rewriteRefs(item, namespace); + if (next !== item) changed = true; + return next; + }); + return changed ? out : node; + } + + const obj = node as Record; + if (typeof obj.$ref === "string") { + const name = obj.$ref.match(DEFS_REF_PATTERN)?.[1]; + if (name) { + return { ...obj, $ref: `#/components/schemas/${namespace}.${name}` }; + } + return obj; + } + + let changed = false; + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const next = rewriteRefs(value, namespace); + if (next !== value) changed = true; + result[key] = next; + } + return changed ? result : obj; +}; + +const asSchemaObject = (schema: unknown): Record | undefined => { + if (schema === undefined || schema === null) return undefined; + if (typeof schema === "boolean") return schema ? {} : undefined; + if (typeof schema !== "object" || Array.isArray(schema)) return undefined; + return schema as Record; +}; + +// The outcome envelope every invoke returns. `execution_paused` rides the +// error channel so generated clients get exactly one response shape. +const ENVELOPE_SCHEMAS: Record = { + ExecutorToolError: { + type: "object", + description: + "Tool failure. `code` is machine-readable; `execution_paused` means the call needs approval: resume it at `resumePath` (or POST /executions/{executionId}/resume).", + required: ["code", "message"], + properties: { + code: { type: "string" }, + message: { type: "string" }, + status: { type: "number" }, + details: {}, + retryable: { type: "boolean" }, + executionId: { type: "string" }, + resumePath: { type: "string" }, + }, + }, + ExecutorToolHttpMeta: { + type: "object", + description: "Upstream transport facts for HTTP-backed tools.", + required: ["status"], + properties: { + status: { type: "number" }, + headers: { type: "object", additionalProperties: { type: "string" } }, + }, + }, +}; + +const responseSchema = (outputSchema: unknown, namespace: string): Record => { + const output = asSchemaObject(outputSchema); + return { + oneOf: [ + { + type: "object", + required: ["ok", "data"], + properties: { + ok: { const: true }, + data: output !== undefined ? rewriteRefs(normalizeRefs(output), namespace) : {}, + http: { $ref: "#/components/schemas/ExecutorToolHttpMeta" }, + }, + }, + { + type: "object", + required: ["ok", "error"], + properties: { + ok: { const: false }, + error: { $ref: "#/components/schemas/ExecutorToolError" }, + }, + }, + ], + }; +}; + +const connectionNamespace = (connection: ToolCatalogConnectionExport): string => + `${connection.integration}.${connection.owner}.${connection.connection}`; + +const uniqueOperationId = (base: string, used: Set): string => { + let candidate = base; + let counter = 2; + while (used.has(candidate)) { + candidate = `${base}_${counter}`; + counter += 1; + } + used.add(candidate); + return candidate; +}; + +export const generateOpenApiSpec = ( + catalog: ToolCatalogExport, + options: GenerateOpenApiSpecOptions = {}, +): GeneratedOpenApiSpec => { + const connections = [...catalog.connections].sort((a, b) => + connectionNamespace(a).localeCompare(connectionNamespace(b)), + ); + + const paths: Record = {}; + const schemas: Record = { ...ENVELOPE_SCHEMAS }; + const tags = new Map(); + const operationIds = new Set(); + let toolCount = 0; + + for (const connection of connections) { + const namespace = connectionNamespace(connection); + for (const [name, schema] of Object.entries(connection.definitions ?? {})) { + schemas[`${namespace}.${name}`] = rewriteRefs(normalizeRefs(schema), namespace); + } + + const tag = namespace; + if (!tags.has(tag)) { + tags.set( + tag, + `Tools from the ${connection.integration} connection "${connection.connection}" (${connection.owner}).`, + ); + } + + const tools = [...connection.tools].sort((a, b) => + String(a.address).localeCompare(String(b.address)), + ); + for (const tool of tools) { + const path = addressToPath(String(tool.address)); + const input = asSchemaObject(tool.inputSchema); + const operation: Record = { + operationId: uniqueOperationId(path.replace(/[^A-Za-z0-9_]/g, "_"), operationIds), + tags: [tag], + ...(tool.description !== undefined && tool.description.length > 0 + ? { summary: tool.description.split("\n")[0], description: tool.description } + : {}), + ...(input !== undefined + ? { + requestBody: { + required: true, + content: { + "application/json": { + schema: rewriteRefs(normalizeRefs(input), namespace), + }, + }, + }, + } + : {}), + responses: { + "200": { + description: + "Tool outcome envelope: `ok: true` with the tool's output, or `ok: false` with a typed error (including `execution_paused` for approval-gated calls).", + content: { + "application/json": { + schema: responseSchema(tool.outputSchema, namespace), + }, + }, + }, + "404": { description: "No tool exists at this path." }, + }, + }; + paths[`/tools/invoke/${path}`] = { post: operation }; + toolCount += 1; + } + } + + const document: Record = { + openapi: "3.1.0", + info: { + title: options.title ?? "Executor tool catalog", + version: options.version ?? "0.0.0", + description: + "Every tool this Executor instance exposes, as one REST operation per tool. Calls execute through Executor, so credentials, policies, and approvals apply. Feed this document to any OpenAPI client generator.", + }, + servers: [{ url: options.serverUrl ?? DEFAULT_SERVER_URL }], + security: [{ bearerAuth: [] }], + tags: [...tags.entries()].map(([name, description]) => ({ name, description })), + paths, + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + description: "Executor API token (EXECUTOR_API_KEY / EXECUTOR_AUTH_TOKEN).", + }, + }, + schemas, + }, + }; + + return { document, toolCount, connectionCount: connections.length }; +}; diff --git a/packages/core/sdk/src/typegen.test.ts b/packages/core/sdk/src/typegen.test.ts index 1dd0afac1..96ad60750 100644 --- a/packages/core/sdk/src/typegen.test.ts +++ b/packages/core/sdk/src/typegen.test.ts @@ -306,19 +306,14 @@ const makeFakeFetch = (respond: (call: FetchCall) => unknown) => { }; describe("generated runtime client", { timeout: 60_000 }, () => { - it("invokes tools through /api/executions and unwraps the outcome", async () => { + it("invokes tools through /api/tools/invoke and unwraps the outcome", async () => { const generated = generateToolProxySource(catalog([githubConnection])); const module = await importGenerated(generated.source); const createClient = module.createExecutorClient as ( options: Record, ) => unknown; - const { calls, fetchImpl } = makeFakeFetch(() => ({ - status: "completed", - text: "", - isError: false, - structured: { result: { ok: true, data: { number: 7 } } }, - })); + const { calls, fetchImpl } = makeFakeFetch(() => ({ ok: true, data: { number: 7 } })); // oxlint-disable-next-line executor/no-double-cast -- test boundary: the client is a dynamically imported Proxy; the cast pins the path this test dials const client = createClient({ @@ -339,12 +334,11 @@ describe("generated runtime client", { timeout: 60_000 }, () => { expect(outcome).toEqual({ ok: true, data: { number: 7 } }); expect(calls).toHaveLength(1); - expect(calls[0]!.url).toBe("http://example.test:4788/api/executions"); + expect(calls[0]!.url).toBe( + `http://example.test:4788/api/tools/invoke/${encodeURIComponent("github.org.main.issues.create")}`, + ); expect(calls[0]!.init.headers.authorization).toBe("Bearer tok_123"); - // oxlint-disable-next-line executor/no-json-parse -- test boundary: asserting on the raw request body the generated client produced - const body = JSON.parse(calls[0]!.init.body) as { code: string }; - expect(body.code).toContain('["github"]["org"]["main"]["issues"]["create"]'); - expect(body.code).toContain('{"title":"hi"}'); + expect(calls[0]!.init.body).toBe('{"title":"hi"}'); }); it("throws ExecutorPausedError with the approval url on paused executions", async () => { @@ -355,9 +349,13 @@ describe("generated runtime client", { timeout: 60_000 }, () => { }; const { fetchImpl } = makeFakeFetch(() => ({ - status: "paused", - text: "Approval required", - structured: { executionId: "exec_42" }, + ok: false, + error: { + code: "execution_paused", + message: "Approval required", + executionId: "exec_42", + resumePath: "/executions/exec_42/resume", + }, })); const client = createClient({ baseUrl: "http://example.test:4788", fetch: fetchImpl }); diff --git a/packages/core/sdk/src/typegen.ts b/packages/core/sdk/src/typegen.ts index fce4e8966..024d04720 100644 --- a/packages/core/sdk/src/typegen.ts +++ b/packages/core/sdk/src/typegen.ts @@ -317,18 +317,6 @@ const readEnvToken = (): string | undefined => { const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const buildInvokeCode = (segments: readonly string[], input: unknown): string => { - const access = segments.map((segment) => \`[\${JSON.stringify(segment)}]\`).join(""); - return [ - \`const __args = \${JSON.stringify(input ?? {})};\`, - \`const __target = tools\${access};\`, - \`if (typeof __target !== "function") {\`, - \` throw new Error(\${JSON.stringify(\`Tool not found: \${segments.join(".")}\`)});\`, - \`}\`, - \`return await __target(__args);\`, - ].join("\\n"); -}; - export interface ExecutorClientHandle { /** Invoke any tool by dotted path, untyped. */ $call: ( @@ -345,6 +333,8 @@ export function ${clientName}(options: ExecutorClientOptions = {}): ExecutorClie const token = options.token ?? readEnvToken(); const fetchImpl = options.fetch ?? globalThis.fetch; + // Calls go through the instance's REST invoke endpoint (the same + // operations the exported OpenAPI document describes). const invoke = async ( segments: readonly string[], input: unknown, @@ -359,22 +349,27 @@ export function ${clientName}(options: ExecutorClientOptions = {}): ExecutorClie throw new ExecutorRequestError("Tool input must be a JSON object", null, ""); } + const path = segments.join("."); const autoApprove = callOptions?.autoApprove ?? options.autoApprove; - const response = await fetchImpl(\`\${origin}/api/executions\`, { - method: "POST", - headers: { - "content-type": "application/json", - ...(token ? { authorization: \`Bearer \${token}\` }: {}), - ...options.headers, + const query = autoApprove ? "?autoApprove=true" : ""; + const response = await fetchImpl( + \`\${origin}/api/tools/invoke/\${encodeURIComponent(path)}\${query}\`, + { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: \`Bearer \${token}\` } : {}), + ...options.headers, + }, + body: JSON.stringify(input ?? {}), + ...(callOptions?.signal ? { signal: callOptions.signal } : {}), }, - body: JSON.stringify({ - code: buildInvokeCode(segments, input), - ...(autoApprove !== undefined ? { autoApprove }: {}), - }), - ...(callOptions?.signal ? { signal: callOptions.signal }: {}), - }); + ); const bodyText = await response.text(); + if (response.status === 404) { + throw new ExecutorRequestError(\`Tool not found: \${path}\`, 404, bodyText); + } if (!response.ok) { throw new ExecutorRequestError( \`Executor request failed with status \${response.status}\`, @@ -389,37 +384,25 @@ export function ${clientName}(options: ExecutorClientOptions = {}): ExecutorClie } catch { throw new ExecutorRequestError("Executor returned a non-JSON response", null, bodyText); } - if (!isRecord(payload)) { + if (!isRecord(payload) || typeof payload.ok !== "boolean") { throw new ExecutorRequestError("Executor returned an unexpected response", null, bodyText); } - if (payload.status === "paused") { - const structured = isRecord(payload.structured) ? payload.structured: {}; + // Approval-gated calls come back as an execution_paused error; surface + // them as a typed exception carrying the resume coordinates. + if (payload.ok === false && isRecord(payload.error) && payload.error.code === "execution_paused") { const executionId = - typeof structured.executionId === "string" ? structured.executionId: null; + typeof payload.error.executionId === "string" ? payload.error.executionId : null; throw new ExecutorPausedError( - typeof payload.text === "string" ? payload.text: "Execution paused awaiting approval", + typeof payload.error.message === "string" + ? payload.error.message + : "Execution paused awaiting approval", executionId, - executionId ? \`\${origin}/resume/\${encodeURIComponent(executionId)}\`: null, - ); - } - - if (payload.isError === true) { - throw new ExecutorRequestError( - typeof payload.text === "string" ? payload.text: "Tool execution failed", - null, - bodyText, + executionId ? \`\${origin}/resume/\${encodeURIComponent(executionId)}\` : null, ); } - const structured = isRecord(payload.structured) ? payload.structured: {}; - const result = structured.result; - // Dynamic tool results already arrive in the outcome envelope; anything - // else (static tools returning raw values) is wrapped as a success. - if (isRecord(result) && typeof result.ok === "boolean") { - return result as ExecutorToolOutcome; - } - return { ok: true, data: result }; + return payload as ExecutorToolOutcome; }; const callByPath = ( diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index cab4018fe..2521cff19 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -72,12 +72,14 @@ "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/emulate": "^0.9.0", "@executor-js/react": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", "effect": "catalog:", + "openapi-typescript": "^7.13.0", "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:" diff --git a/packages/plugins/openapi/src/sdk/typegen-scale.test.ts b/packages/plugins/openapi/src/sdk/typegen-scale.test.ts index 76d9965f8..490fc81b7 100644 --- a/packages/plugins/openapi/src/sdk/typegen-scale.test.ts +++ b/packages/plugins/openapi/src/sdk/typegen-scale.test.ts @@ -1,48 +1,65 @@ // --------------------------------------------------------------------------- -// `executor generate` at catalog scale: 10,000 tools from one OpenAPI spec. +// `executor generate` at catalog scale: 10,000+ tools across MANY specs. // -// The typed-proxy pipeline must hold up on instances with five-digit tool -// counts, end to end through the REAL ingestion path (addSpec compiles the -// spec, a connection persists the tool rows) rather than hand-built fixtures: +// Real instances reach five-digit tool counts by accumulating integrations, +// not from one giant spec. This suite builds that shape through the real +// ingestion path (addSpec compiles each spec, a connection persists the tool +// rows), mixing: +// - real service specs served by @executor-js/emulate emulators (github, +// stripe), exactly what a user adding those integrations gets, +// - a fleet of synthetic OpenAPI specs topping the catalog up past 10,000 +// tools total. +// +// Then the full generate pipeline runs once over the combined catalog: // - `tools.export` returns every tool in one read, -// - `generateToolProxySource` emits the file without blowing time or memory -// (chunked schema compilation: one whole-catalog compiler pass is -// super-linear and takes 30s+ at this size), -// - the generated source is valid strict TypeScript, verified with the real -// compiler, and spot-checked types resolve to the right shapes. +// - `generateOpenApiSpec` emits the OpenAPI 3.1 document (the primary +// artifact) and the REAL `openapi-typescript` generator accepts it, +// proving third-party client generators can consume the output, +// - `generateToolProxySource` emits the optional TypeScript client and it +// typechecks under strict mode. // -// Budgets are deliberately loose (CI machines vary) but tight enough to catch -// a regression to per-tool or whole-catalog compilation. +// Time budgets are loose regression tripwires (CI machines vary), tight +// enough to catch a regression to per-tool or whole-catalog compilation. // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; +import openapiTS, { astToString } from "openapi-typescript"; import * as ts from "typescript"; +import { createEmulator, type Emulator, type ServiceName } from "@executor-js/emulate"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, createExecutor, + generateOpenApiSpec, generateToolProxySource, } from "@executor-js/sdk"; import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { openApiPlugin } from "./plugin"; -const TOOL_COUNT = 10_000; +const TOTAL_TOOL_TARGET = 10_000; +const SYNTHETIC_SPEC_COUNT = 8; + +// Real service specs via local emulators. Two is enough to prove the +// real-spec path; the synthetic fleet carries the volume. +const EMULATED_SERVICES: readonly ServiceName[] = ["github", "stripe"]; +const EMULATOR_BASE_PORT = 4720; + +// --------------------------------------------------------------------------- +// Synthetic spec fleet +// --------------------------------------------------------------------------- -// Build a 10k-operation spec with realistic shape variety: per-operation -// parameter schemas, shared component refs, and enough distinct field names -// that deduplication cannot collapse the work. -const buildScaleSpec = (toolCount: number): string => { +const buildSyntheticSpec = (specIndex: number, toolCount: number): string => { const paths: Record = {}; for (let index = 0; index < toolCount; index += 1) { - paths[`/resources${index % 100}/r${index}`] = { + paths[`/resources${index % 50}/r${index}`] = { get: { operationId: `res.op${index}`, - summary: `Operation ${index}`, + summary: `Spec ${specIndex} operation ${index}`, parameters: [ { name: "id", in: "query", required: true, schema: { type: "string" } }, { name: `filter${index % 250}`, in: "query", schema: { type: "string" } }, @@ -71,8 +88,8 @@ const buildScaleSpec = (toolCount: number): string => { // @effect-diagnostics-next-line preferSchemaOverJson:off return JSON.stringify({ openapi: "3.0.0", - info: { title: "Scale", version: "1.0.0" }, - servers: [{ url: "https://scale.example.test" }], + info: { title: `Scale ${specIndex}`, version: "1.0.0" }, + servers: [{ url: `https://scale${specIndex}.example.test` }], security: [{ apiKey: [] }], paths, components: { @@ -98,6 +115,10 @@ const buildScaleSpec = (toolCount: number): string => { }); }; +// --------------------------------------------------------------------------- +// Verification helpers +// --------------------------------------------------------------------------- + const typecheck = (source: string, extraSource: string): readonly string[] => { const fileName = "generated.ts"; const fullSource = `${source}\n${extraSource}`; @@ -127,9 +148,9 @@ const typecheck = (source: string, extraSource: string): readonly string[] => { .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); }; -describe("typed proxy generation at 10k-tool scale", () => { +describe("typed proxy generation at 10k-tool scale (multi-spec)", () => { it.effect( - "exports, generates, and typechecks a 10,000-tool catalog", + "ingests emulator + synthetic specs past 10k tools, exports, and generates both artifacts", () => Effect.scoped( Effect.gen(function* () { @@ -142,59 +163,131 @@ describe("typed proxy generation at 10k-tool scale", () => { }), ); - const added = yield* executor.openapi.addSpec({ - spec: { kind: "blob", value: buildScaleSpec(TOOL_COUNT) }, - slug: "scale", - }); - expect(added.toolCount).toBe(TOOL_COUNT); - - yield* executor.connections.create({ - owner: "org", - name: ConnectionName.make("main"), - integration: IntegrationSlug.make("scale"), - template: AuthTemplateSlug.make("apikey-0"), - value: "token", - }); + /** Register a spec and connect it using the first auth template the + * spec derives (emulator specs carry real securitySchemes; + * synthetic specs derive `apikey-0`). */ + const addAndConnect = (input: { + slug: string; + spec: { kind: "url"; url: string } | { kind: "blob"; value: string }; + }) => + Effect.gen(function* () { + const added = yield* executor.openapi.addSpec({ + spec: input.spec, + slug: input.slug, + }); + const config = yield* executor.openapi.getConfig(input.slug); + const template = config?.authenticationTemplate?.[0]; + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(input.slug), + template: AuthTemplateSlug.make(template ? String(template.slug) : "apikey-0"), + value: "scale-token", + }); + return added.toolCount; + }); + // Real service specs from local emulators, registered by URL the + // same way a user pointing Executor at a service would. + const emulators: Emulator[] = []; + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.allSettled(emulators.map((emulator) => emulator.close()))), + ); + let ingestedTools = 0; + for (const [index, service] of EMULATED_SERVICES.entries()) { + const emulator = yield* Effect.promise(() => + createEmulator({ service, port: EMULATOR_BASE_PORT + index }), + ); + emulators.push(emulator); + const count = yield* addAndConnect({ + slug: `emu_${service}`, + spec: { kind: "url", url: emulator.openapiUrl }, + }); + expect(count).toBeGreaterThan(0); + ingestedTools += count; + } + + // Synthetic fleet tops the catalog up past the target. + const remaining = TOTAL_TOOL_TARGET - ingestedTools; + const perSpec = Math.ceil(remaining / SYNTHETIC_SPEC_COUNT); + for (let specIndex = 0; specIndex < SYNTHETIC_SPEC_COUNT; specIndex += 1) { + const count = Math.min(perSpec, remaining - specIndex * perSpec); + if (count <= 0) break; + ingestedTools += yield* addAndConnect({ + slug: `scale_${specIndex}`, + spec: { kind: "blob", value: buildSyntheticSpec(specIndex, count) }, + }); + } + expect(ingestedTools).toBeGreaterThanOrEqual(TOTAL_TOOL_TARGET); + + // One bulk read for the whole catalog. const exportStart = performance.now(); - const exported = yield* executor.tools.export({ - integration: IntegrationSlug.make("scale"), - }); + const exported = yield* executor.tools.export(); const exportMs = performance.now() - exportStart; const exportedCount = exported.connections.reduce( (sum, connection) => sum + connection.tools.length, 0, ); - expect(exportedCount).toBe(TOOL_COUNT); + expect(exportedCount).toBeGreaterThanOrEqual(TOTAL_TOOL_TARGET); + expect(exported.connections.length).toBeGreaterThanOrEqual( + EMULATED_SERVICES.length + SYNTHETIC_SPEC_COUNT, + ); + + // Primary artifact: the OpenAPI document. + const specStart = performance.now(); + const spec = generateOpenApiSpec(exported, { + serverUrl: "http://localhost:4788/api", + }); + const specMs = performance.now() - specStart; + expect(spec.toolCount).toBe(exportedCount); + const paths = spec.document.paths as Record; + expect(Object.keys(paths).length).toBe(exportedCount); + // Real-spec tools land beside synthetic ones in the same document. + expect( + Object.keys(paths).some((path) => path.startsWith("/tools/invoke/emu_github.")), + ).toBe(true); + expect(Object.keys(paths).some((path) => path.startsWith("/tools/invoke/scale_0."))).toBe( + true, + ); + // Interop proof: the real openapi-typescript generator consumes the + // document and emits a paths interface covering the catalog. + const otsStart = performance.now(); + const ast = yield* Effect.promise(() => + openapiTS( + // oxlint-disable-next-line executor/no-double-cast -- test boundary: openapi-typescript's OpenAPI3 input type vs our Record document; the generator validates it at runtime + spec.document as unknown as Parameters[0], + ), + ); + const otsSource = astToString(ast); + const otsMs = performance.now() - otsStart; + expect(otsSource).toContain("export interface paths"); + const otsPathCount = (otsSource.match(/"\/tools\/invoke\//g) ?? []).length; + expect(otsPathCount).toBe(exportedCount); + + // Secondary artifact: the self-contained TypeScript client. const generateStart = performance.now(); const generated = generateToolProxySource(exported); const generateMs = performance.now() - generateStart; - expect(generated.toolCount).toBe(TOOL_COUNT); + expect(generated.toolCount).toBe(exportedCount); // Regression tripwires, not benchmarks: whole-catalog single-pass - // compilation measured 30s+ here and per-tool passes are far worse; - // the chunked path runs in well under a second. 15s/30s absorb slow - // CI machines while still failing on a complexity regression. + // schema compilation measured 30s+ at this size and per-tool passes + // are far worse; the chunked path runs in well under a second. expect(exportMs).toBeLessThan(15_000); + expect(specMs).toBeLessThan(15_000); expect(generateMs).toBeLessThan(30_000); + expect(otsMs).toBeLessThan(120_000); - // Every tool surfaced in the generated interface exactly once. The - // plugin derives paths from the URL (`/resources77/r7777` → - // `resources77.resOp7777`), so count the leaf entries. - const opMatches = generated.source.match(/resOp\d+: ExecutorToolFn { expect(diagnostics).toEqual([]); }), ), - { timeout: 180_000 }, + { timeout: 300_000 }, ); });