diff --git a/.changeset/typed-api-proxy-generate.md b/.changeset/typed-api-proxy-generate.md new file mode 100644 index 000000000..c25465015 --- /dev/null +++ b/.changeset/typed-api-proxy-generate.md @@ -0,0 +1,19 @@ +--- +"@executor-js/sdk": minor +"executor": minor +--- + +Add `executor generate`: export the tool catalog as an OpenAPI document or a typed TypeScript client, backed by direct REST tool invocation. + +`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 c6e4df0e0..70d41c219 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 { generateOpenApiSpec, generateToolProxySource } from "@executor-js/sdk/core"; import { decodeAccessTokenClaims, discoverCliLogin, @@ -2710,6 +2713,161 @@ const mcpCommand = Command.make( }), ).pipe(Command.withDescription("Start an MCP server over stdio")); +// --------------------------------------------------------------------------- +// 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.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, + 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, + }, + ({ format, 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 totalTools = filtered.connections.reduce((sum, entry) => sum + entry.tools.length, 0); + if (totalTools === 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 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).`, + ); + } + + 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( + "Export this instance's tool catalog as an OpenAPI document (default) or a typed TypeScript client", + ), +); + // --------------------------------------------------------------------------- // Service — register the daemon with the OS so it survives app-quit + restart // --------------------------------------------------------------------------- @@ -3112,6 +3270,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..6a30d89ee 100644 --- a/apps/docs/local/cli.mdx +++ b/apps/docs/local/cli.mdx @@ -109,3 +109,44 @@ resume it: ```bash executor resume --execution-id exec_123 ``` + +## Generate a typed client + +`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.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 +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/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 8e5818d7c..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, @@ -49,5 +51,189 @@ 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", + }); + }), + ), + ) + .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 767c6609d..4d732b5db 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"; @@ -58,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 // --------------------------------------------------------------------------- @@ -82,4 +106,36 @@ 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, + }), + ) + .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/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/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 new file mode 100644 index 000000000..96ad60750 --- /dev/null +++ b/packages/core/sdk/src/typegen.test.ts @@ -0,0 +1,514 @@ +// --------------------------------------------------------------------------- +// 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/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(() => ({ 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/tools/invoke/${encodeURIComponent("github.org.main.issues.create")}`, + ); + expect(calls[0]!.init.headers.authorization).toBe("Bearer tok_123"); + expect(calls[0]!.init.body).toBe('{"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(() => ({ + 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 }); + 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..024d04720 --- /dev/null +++ b/packages/core/sdk/src/typegen.ts @@ -0,0 +1,511 @@ +// --------------------------------------------------------------------------- +// 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); + +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; + + // 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, + 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 path = segments.join("."); + const autoApprove = callOptions?.autoApprove ?? options.autoApprove; + 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 } : {}), + }, + ); + + 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}\`, + 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) || typeof payload.ok !== "boolean") { + throw new ExecutorRequestError("Executor returned an unexpected response", null, bodyText); + } + + // 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 payload.error.executionId === "string" ? payload.error.executionId : null; + throw new ExecutorPausedError( + typeof payload.error.message === "string" + ? payload.error.message + : "Execution paused awaiting approval", + executionId, + executionId ? \`\${origin}/resume/\${encodeURIComponent(executionId)}\` : null, + ); + } + + return payload as ExecutorToolOutcome; + }; + + 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/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 new file mode 100644 index 000000000..490fc81b7 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/typegen-scale.test.ts @@ -0,0 +1,306 @@ +// --------------------------------------------------------------------------- +// `executor generate` at catalog scale: 10,000+ tools across MANY specs. +// +// 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, +// - `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. +// +// 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 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 +// --------------------------------------------------------------------------- + +const buildSyntheticSpec = (specIndex: number, toolCount: number): string => { + const paths: Record = {}; + for (let index = 0; index < toolCount; index += 1) { + paths[`/resources${index % 50}/r${index}`] = { + get: { + operationId: `res.op${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" } }, + { 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 ${specIndex}`, version: "1.0.0" }, + servers: [{ url: `https://scale${specIndex}.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" } }, + }, + }, + }, + }); +}; + +// --------------------------------------------------------------------------- +// Verification helpers +// --------------------------------------------------------------------------- + +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 (multi-spec)", () => { + it.effect( + "ingests emulator + synthetic specs past 10k tools, exports, and generates both artifacts", + () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), + memoryCredentialsPlugin(), + ] as const, + }), + ); + + /** 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(); + const exportMs = performance.now() - exportStart; + const exportedCount = exported.connections.reduce( + (sum, connection) => sum + connection.tools.length, + 0, + ); + 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(exportedCount); + + // Regression tripwires, not benchmarks: whole-catalog single-pass + // 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); + + // The full TypeScript client typechecks under strict mode, and a + // consumer gets real types out of a tool in the middle of the + // synthetic fleet. + const diagnostics = typecheck( + generated.source, + ` + const client = createExecutorClient(); + async function main() { + const outcome = await client.scale_3.org.main.resources7.resOp57({ id: "x" }); + if (outcome.ok) { + const total: number | undefined = outcome.data.total; + const itemId: string | undefined = outcome.data.item?.id; + void total; + void itemId; + } + } + void main; + `, + ); + expect(diagnostics).toEqual([]); + }), + ), + { timeout: 300_000 }, + ); +});