History

Add executor generate: typed TypeScript client for the tool catalog

Running executor generate against an instance writes one self-contained TypeScript file: a dependency-free Proxy-based runtime client plus full input/output types for every visible tool, so client.github.org.main.issues.create({ title }) is typed end to end and executes through the server's /api/executions endpoint (auth, policies, approvals included). New pieces: - tools.export SDK surface + GET /tools/export: the bulk schema-bearing catalog read (schemas + trimmed shared $defs, grouped per connection, policy-filtered), one request regardless of catalog size. - compileToolChunkTypeScript: compiles many tools per compiler pass. Per-tool passes pay fixed overhead thousands of times and one whole-catalog pass is super-linear (30s+ at 10k tools); 200-tool chunks generate a 10k-tool catalog in ~400ms. - generateToolProxySource: assembles the generated file (type-only namespaces per connection, ExecutorTools path tree, embedded runtime). - executor generate CLI command with --output/--integration/--connection/ --include-static, reusing the server-target and auth machinery. Tested down to the wire: generated source is typechecked with the real compiler (valid calls accept, invalid reject), the transpiled runtime is imported and run against a fake fetch (completed/paused/injection paths), and a scale test ingests a 10,000-operation OpenAPI spec through addSpec and proves export + generate + strict typecheck hold up with regression tripwires on time.

Rhys Sullivan committed 4 months ago

Commit dabe51f1 parent e39dbcf14 files · +1733 −5Browse files

Unified · Split

14 files
  1. .changeset/typed-api-proxy-generate.md +16 −0
  2. apps/cli/src/main.ts +103 −0
  3. apps/docs/local/cli.mdx +25 −0
  4. packages/core/api/src/handlers/tools.ts +15 −0
  5. packages/core/api/src/tools/api.ts +12 −0
  6. packages/core/execution/src/promise.ts +2 −0
  7. packages/core/sdk/src/executor.ts +131 −1
  8. packages/core/sdk/src/index.ts +21 −2
  9. packages/core/sdk/src/schema-types.ts +108 −0
  10. packages/core/sdk/src/shared.ts +7 −1
  11. packages/core/sdk/src/typegen.test.ts +516 −0
  12. packages/core/sdk/src/typegen.ts +528 −0
  13. packages/core/sdk/src/types.ts +36 −1
  14. packages/plugins/openapi/src/sdk/typegen-scale.test.ts +213 −0
.changeset/typed-api-proxy-generate.md+16−0
@@ -0,0 +1,16 @@
1+---
2+"@executor-js/sdk": minor
3+"executor": minor
4+---
5+
6+Add `executor generate`: emit a typed TypeScript client for an instance's tool catalog.
7+
8+Running `executor generate` against a server writes a single self-contained
9+file (default `executor.gen.ts`) with a dependency-free runtime client and
10+full input/output types for every visible tool, so
11+`client.github.org.main.issues.create({ title })` is typed end to end and
12+calls go through the server's execution endpoint (auth, policies, and
13+approvals included). New `GET /tools/export` endpoint and
14+`executor.tools.export()` SDK surface return the whole schema-bearing catalog
15+in one read; generation compiles schemas in chunks and stays fast at
16+10,000-tool scale.
apps/cli/src/main.ts+103−0
@@ -69,6 +69,8 @@ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; · 68 unmodified lines
6969
7070 import { ExecutorApi, checkForUpdate } from "@executor-js/api";
7171 import {
72+ ConnectionName,
73+ IntegrationSlug,
7274 getExecutorServerAuthorizationHeader,
7375 normalizeExecutorServerConnection,
7476 normalizeExecutorServerOrigin,
@@ -77,6 +79,7 @@ import { · 2 unmodified lines
7779 type ExecutorServerConnection,
7880 type ExecutorServerConnectionInput,
7981 } from "@executor-js/sdk/shared";
82+import { generateToolProxySource } from "@executor-js/sdk/core";
8083 import {
8184 decodeAccessTokenClaims,
8285 discoverCliLogin,
@@ -2710,6 +2713,105 @@ const mcpCommand = Command.make( · 2627 unmodified lines
27102713 }),
27112714 ).pipe(Command.withDescription("Start an MCP server over stdio"));
27122715
2716+// ---------------------------------------------------------------------------
2717+// Generate — emit a typed TypeScript client for the instance's tool catalog
2718+// ---------------------------------------------------------------------------
2719+
2720+const generateCommand = Command.make(
2721+ "generate",
2722+ {
2723+ output: Options.string("output").pipe(
2724+ Options.withAlias("o"),
2725+ Options.withDefault("executor.gen.ts"),
2726+ Options.withDescription("Path of the generated TypeScript file."),
2727+ ),
2728+ integration: Options.string("integration").pipe(
2729+ Options.optional,
2730+ Options.withDescription("Only generate tools from this integration slug."),
2731+ ),
2732+ connection: Options.string("connection").pipe(
2733+ Options.optional,
2734+ Options.withDescription("Only generate tools from this connection name."),
2735+ ),
2736+ includeStatic: Options.boolean("include-static").pipe(
2737+ Options.withDefault(false),
2738+ Options.withDescription(
2739+ "Include Executor's built-in static tools (executor.*, search, describe.*).",
2740+ ),
2741+ ),
2742+ baseUrl: serverBaseUrl,
2743+ server: serverProfile,
2744+ scope,
2745+ },
2746+ ({ output, integration, connection, includeStatic, baseUrl, server, scope }) =>
2747+ Effect.gen(function* () {
2748+ applyScope(scope);
2749+ const target = serverTargetFromOptions({ baseUrl, server });
2750+ const serverConnection = yield* resolveExecutorServerConnection(target);
2751+ const client = yield* makeApiClient(serverConnection);
2752+
2753+ const catalog = yield* client.tools
2754+ .export({
2755+ query: {
2756+ ...(Option.isSome(integration)
2757+ ? { integration: IntegrationSlug.make(integration.value) }
2758+ : {}),
2759+ ...(Option.isSome(connection)
2760+ ? { connection: ConnectionName.make(connection.value) }
2761+ : {}),
2762+ },
2763+ })
2764+ .pipe(Effect.mapError(toError));
2765+
2766+ const filtered = includeStatic
2767+ ? catalog
2768+ : {
2769+ connections: catalog.connections
2770+ .map((entry) => ({
2771+ ...entry,
2772+ tools: entry.tools.filter((tool) => tool.static !== true),
2773+ }))
2774+ .filter((entry) => entry.tools.length > 0),
2775+ };
2776+
2777+ const generated = yield* Effect.try({
2778+ try: () => generateToolProxySource(filtered),
2779+ catch: (cause) =>
2780+ cause instanceof Error
2781+ ? cause
2782+ : new Error(`Failed to generate TypeScript source: ${String(cause)}`),
2783+ });
2784+
2785+ if (generated.toolCount === 0) {
2786+ return yield* Effect.fail(
2787+ new Error(
2788+ [
2789+ "No tools matched, nothing to generate.",
2790+ `Server: ${serverConnection.origin}`,
2791+ `Check filters, or add an integration first: ${cliPrefix} web`,
2792+ ].join("\n"),
2793+ ),
2794+ );
2795+ }
2796+
2797+ const fs = yield* FileSystem.FileSystem;
2798+ const path = yield* PlatformPath.Path;
2799+ const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
2800+ yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
2801+ yield* fs.writeFileString(outputPath, generated.source);
2802+
2803+ console.log(
2804+ `Generated ${outputPath} (${generated.toolCount} tools, ${generated.connectionCount} connections).`,
2805+ );
2806+ console.log("");
2807+ console.log("Use it:");
2808+ console.log(` import { createExecutorClient } from "./${path.basename(output, ".ts")}";`);
2809+ console.log(` const executor = createExecutorClient();`);
2810+ }).pipe(Effect.mapError(toError)),
2811+).pipe(
2812+ Command.withDescription("Generate a typed TypeScript client for this instance's tool catalog"),
2813+);
2814+
27132815 // ---------------------------------------------------------------------------
27142816 // Service — register the daemon with the OS so it survives app-quit + restart
27152817 // ---------------------------------------------------------------------------
@@ -3112,6 +3214,7 @@ const root = Command.make("executor").pipe( · 396 unmodified lines
31123214 daemonCommand,
31133215 serviceCommand,
31143216 mcpCommand,
3217+ generateCommand,
31153218 openCommand,
31163219 docsCommand,
31173220 ] as const),
apps/docs/local/cli.mdx+25−0
@@ -109,3 +109,28 @@ resume it: · 108 unmodified lines
109109 ```bash
110110 executor resume --execution-id exec_123
111111 ```
112+
113+## Generate a typed client
114+
115+`executor generate` writes a single self-contained TypeScript file with full
116+input/output types for every tool in your catalog, plus a dependency-free
117+runtime client. Drop it into any TypeScript project and every call is typed
118+end to end, while still executing through Executor (auth, policies, and
119+approvals included):
120+
121+```bash
122+executor generate # writes executor.gen.ts
123+executor generate -o src/executor.ts # custom output path
124+executor generate --integration github # only one integration
125+```
126+
127+```ts
128+import { createExecutorClient } from "./executor.gen";
129+
130+const executor = createExecutorClient(); // EXECUTOR_API_KEY / EXECUTOR_AUTH_TOKEN
131+const created = await executor.github.org.main.issues.create({ title: "Hi" });
132+if (created.ok) console.log(created.data.number);
133+```
134+
135+Calls that pause for approval throw `ExecutorPausedError` with the approval
136+URL. Re-run `executor generate` whenever your catalog changes.
packages/core/api/src/handlers/tools.ts+15−0
@@ -49,5 +49,20 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 48 unmodified lines
4949 return schema;
5050 }),
5151 ),
52+ )
53+ .handle("export", ({ query }) =>
54+ capture(
55+ Effect.gen(function* () {
56+ const executor = yield* ExecutorService;
57+ return yield* executor.tools.export({
58+ integration: query.integration,
59+ owner: query.owner,
60+ connection: query.connection,
61+ query: query.query,
62+ includeAnnotations: query.includeAnnotations === "true",
63+ includeBlocked: query.includeBlocked === "true",
64+ });
65+ }),
66+ ),
5267 ),
5368 );
packages/core/api/src/tools/api.ts+12−0
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 InternalError,
1717 Owner,
1818 ToolAddress,
19+ ToolCatalogExport,
1920 ToolNotFoundError,
2021 ToolSchemaView,
2122 } from "@executor-js/sdk/shared";
@@ -82,4 +83,15 @@ export const ToolsApi = HttpApiGroup.make("tools") · 60 unmodified lines
8283 success: ToolSchemaView,
8384 error: [InternalError, ToolNotFound],
8485 }),
86+ )
87+ .add(
88+ // Bulk schema-bearing read for codegen (`executor generate`): every
89+ // visible tool's input/output JSON schema grouped per connection with the
90+ // connection's shared `$defs`. One request regardless of catalog size —
91+ // per-tool `schema` round trips do not scale to 10k-tool catalogs.
92+ HttpApiEndpoint.get("export", "/tools/export", {
93+ query: ListToolsQuery,
94+ success: ToolCatalogExport,
95+ error: InternalError,
96+ }),
8597 );
packages/core/execution/src/promise.ts+2−0
@@ -109,6 +109,8 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => { · 108 unmodified lines
109109 fromPromise(() => pe.tools.list(filter)),
110110 schema: (address: Parameters<PromiseExecutor["tools"]["schema"]>[0]) =>
111111 fromPromise(() => pe.tools.schema(address)),
112+ export: (filter?: Parameters<PromiseExecutor["tools"]["export"]>[0]) =>
113+ fromPromise(() => pe.tools.export(filter)),
112114 },
113115 providers: {
114116 list: () => fromPromise(() => pe.providers.list()),
packages/core/sdk/src/executor.ts+131−1
@@ -136,7 +136,13 @@ import { · 135 unmodified lines
136136 ORG_SUBJECT,
137137 type ExecutorOwnerPolicyContext,
138138 } from "./owner-policy";
139−import { ToolSchemaView, type IntegrationDetectionResult } from "./types";
139+import {
140+ ToolCatalogExport,
141+ ToolSchemaView,
142+ type IntegrationDetectionResult,
143+ type ToolCatalogConnectionExport,
144+ type ToolCatalogToolExport,
145+} from "./types";
140146 import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool";
141147 import { buildToolTypeScriptPreview } from "./schema-types";
142148 import { collectReferencedDefinitions } from "./schema-refs";
@@ -304,6 +310,9 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 161 unmodified lines
304310 readonly tools: {
305311 readonly list: (filter?: ToolListFilter) => Effect.Effect<readonly Tool[], StorageFailure>;
306312 readonly schema: (address: ToolAddress) => Effect.Effect<ToolSchemaView | null, StorageFailure>;
313+ /** Bulk schema-bearing read for codegen: every visible tool's input/output
314+ * JSON schema, grouped per connection with trimmed shared `$defs`. */
315+ readonly export: (filter?: ToolListFilter) => Effect.Effect<ToolCatalogExport, StorageFailure>;
307316 };
308317
309318 readonly providers: {
@@ -2752,6 +2761,126 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2442 unmodified lines
27522761 });
27532762 });
27542763
2764+ // The bulk schema-bearing read behind `executor generate`. One pass over
2765+ // full tool rows (schemas included, unlike the projected `toolsList`),
2766+ // policy-filtered like `toolsList`, grouped per connection with that
2767+ // connection's `$defs` trimmed to the referenced subset. Kept as a single
2768+ // surface instead of N `toolSchema` calls: a 10k-tool catalog must not pay
2769+ // 10k round trips or 10k per-tool definition queries.
2770+ const toolsExport = (
2771+ filter?: ToolListFilter,
2772+ ): Effect.Effect<ToolCatalogExport, StorageFailure> =>
2773+ Effect.gen(function* () {
2774+ yield* syncStaleConnectionTools;
2775+ const rows = yield* core.findMany("tool", {
2776+ where: (b: AnyCb) =>
2777+ b.and(
2778+ filter?.integration === undefined
2779+ ? true
2780+ : b("integration", "=", String(filter.integration)),
2781+ filter?.owner === undefined ? true : b("owner", "=", filter.owner),
2782+ filter?.connection === undefined
2783+ ? true
2784+ : b("connection", "=", String(filter.connection)),
2785+ ),
2786+ });
2787+ const includeBlocked = filter?.includeBlocked ?? false;
2788+ const policyRules = yield* listActivePolicyRuleSet();
2789+
2790+ type ConnectionGroup = {
2791+ readonly owner: Owner;
2792+ readonly integration: IntegrationSlug;
2793+ readonly connection: ConnectionName;
2794+ readonly tools: ToolCatalogToolExport[];
2795+ };
2796+ const groups = new Map<string, ConnectionGroup>();
2797+ const groupFor = (tool: Tool): ConnectionGroup => {
2798+ const key = `${tool.owner}${tool.integration}${tool.connection}`;
2799+ let group = groups.get(key);
2800+ if (!group) {
2801+ group = {
2802+ owner: tool.owner,
2803+ integration: tool.integration,
2804+ connection: tool.connection,
2805+ tools: [],
2806+ };
2807+ groups.set(key, group);
2808+ }
2809+ return group;
2810+ };
2811+
2812+ const visible = (tool: Tool): Effect.Effect<boolean, StorageFailure> =>
2813+ Effect.gen(function* () {
2814+ if (!matchesToolFilter(tool, filter)) return false;
2815+ if (includeBlocked) return true;
2816+ const effective = yield* resolvePolicyFromRuleSet(
2817+ normalizedPolicyId(tool),
2818+ policyRules,
2819+ tool.annotations?.requiresApproval,
2820+ );
2821+ return effective.action !== "block";
2822+ });
2823+
2824+ for (const row of rows) {
2825+ const tool = rowToTool(row);
2826+ if (!(yield* visible(tool))) continue;
2827+ groupFor(tool).tools.push({
2828+ address: tool.address,
2829+ name: String(tool.name),
2830+ description: tool.description,
2831+ inputSchema: tool.inputSchema,
2832+ outputSchema: tool.outputSchema,
2833+ });
2834+ }
2835+ for (const entry of staticTools.values()) {
2836+ const tool = staticToolToTool(entry);
2837+ if (!(yield* visible(tool))) continue;
2838+ groupFor(tool).tools.push({
2839+ address: tool.address,
2840+ name: String(tool.name),
2841+ description: tool.description,
2842+ inputSchema: tool.inputSchema,
2843+ outputSchema: tool.outputSchema,
2844+ static: true,
2845+ });
2846+ }
2847+
2848+ const connectionsOut: ToolCatalogConnectionExport[] = [];
2849+ for (const group of groups.values()) {
2850+ // Static pseudo-connections have no definition rows; skip the query.
2851+ const isStatic = group.tools.every((tool) => tool.static === true);
2852+ let definitions: Record<string, unknown> | undefined;
2853+ if (!isStatic) {
2854+ const definitionRows = yield* core.findMany("definition", {
2855+ where: (b: AnyCb) =>
2856+ b.and(
2857+ byOwner(group.owner)(b),
2858+ b("integration", "=", String(group.integration)),
2859+ b("connection", "=", String(group.connection)),
2860+ ),
2861+ });
2862+ const defs = new Map<string, unknown>();
2863+ for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));
2864+ const referenced = collectReferencedDefinitions(
2865+ group.tools.flatMap((tool) => [tool.inputSchema, tool.outputSchema]),
2866+ defs,
2867+ );
2868+ if (Object.keys(referenced).length > 0) {
2869+ definitions = referenced;
2870+ }
2871+ }
2872+ connectionsOut.push({
2873+ owner: group.owner,
2874+ integration: group.integration,
2875+ connection: group.connection,
2876+ tools: group.tools,
2877+ ...(definitions !== undefined ? { definitions } : {}),
2878+ });
2879+ }
2880+
2881+ return ToolCatalogExport.make({ connections: connectionsOut });
2882+ });
2883+
27552884 // ------------------------------------------------------------------
27562885 // Providers
27572886 // ------------------------------------------------------------------
@@ -3465,6 +3594,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 707 unmodified lines
34653594 tools: {
34663595 list: toolsList,
34673596 schema: toolSchema,
3597+ export: toolsExport,
34683598 },
34693599 providers: {
34703600 list: providersList,
packages/core/sdk/src/index.ts+21−2
@@ -99,7 +99,13 @@ export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; · 98 unmodified lines
9999 export type { CredentialProvider, ProviderEntry } from "./provider";
100100
101101 // Public projections / detection.
102−export { ToolSchemaView, IntegrationDetectionResult } from "./types";
102+export {
103+ ToolCatalogConnectionExport,
104+ ToolCatalogExport,
105+ ToolCatalogToolExport,
106+ ToolSchemaView,
107+ IntegrationDetectionResult,
108+} from "./types";
103109
104110 // Core schema.
105111 export {
@@ -329,7 +335,20 @@ export { · 223 unmodified lines
329335 } from "./config";
330336
331337 // The one TS-preview generator plugins assert against.
332−export { buildToolTypeScriptPreview } from "./schema-types";
338+export {
339+ buildToolTypeScriptPreview,
340+ compileToolChunkTypeScript,
341+ type ToolChunkSchemaEntry,
342+ type ToolChunkTypeScript,
343+ type ToolChunkTypeScriptEntry,
344+} from "./schema-types";
345+
346+// Typed tool-proxy source generation (the `executor generate` backend).
347+export {
348+ generateToolProxySource,
349+ type GeneratedToolProxy,
350+ type GenerateToolProxyOptions,
351+} from "./typegen";
333352
334353 // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions.
335354 export { InternalError } from "./api-errors";
packages/core/sdk/src/schema-types.ts+108−0
@@ -789,6 +789,114 @@ export type ToolTypeScriptPreview = { · 788 unmodified lines
789789 typeScriptDefinitions?: Record<string, string>;
790790 };
791791
792+// ---------------------------------------------------------------------------
793+// Chunked tool compilation — many tools per compiler pass.
794+//
795+// `buildToolTypeScriptPreview` compiles ONE tool per pass; at catalog scale
796+// (the `executor generate` typegen walks every tool, potentially 10k+) the
797+// per-pass overhead dominates and compile time grows super-linearly with the
798+// number of declarations in a single pass. This entry point compiles a CHUNK
799+// of tools in one pass by wrapping every tool's input/output as a property of
800+// one root object, then splits the compiled root interface back into per-tool
801+// type strings. Callers pick the chunk size; ~200 keeps each pass linear.
802+// ---------------------------------------------------------------------------
803+
804+export type ToolChunkSchemaEntry = {
805+ readonly key: string;
806+ readonly inputSchema?: unknown;
807+ readonly outputSchema?: unknown;
808+};
809+
810+export type ToolChunkTypeScriptEntry = {
811+ readonly inputTypeScript?: string;
812+ readonly outputTypeScript?: string;
813+};
814+
815+export type ToolChunkTypeScript = {
816+ readonly tools: ReadonlyMap<string, ToolChunkTypeScriptEntry>;
817+ readonly definitions: Record<string, string>;
818+};
819+
820+const CHUNK_INPUT_SUFFIX = "__in";
821+const CHUNK_OUTPUT_SUFFIX = "__out";
822+
823+/**
824+ * Compile a chunk of tools' input/output schemas in one compiler pass.
825+ *
826+ * `key`s must be unique valid identifiers within the chunk (the caller indexes
827+ * them, e.g. `t0`, `t1`, …). Shared `$defs` are compiled once per chunk and
828+ * returned as named `definitions`; the per-tool type strings reference them by
829+ * name. Throws when the underlying compiler rejects a schema — callers decide
830+ * whether to retry tool-by-tool or degrade to `unknown`.
831+ */
832+export const compileToolChunkTypeScript = (
833+ tools: ReadonlyArray<ToolChunkSchemaEntry>,
834+ defs: ReadonlyMap<string, unknown>,
835+ options: TypeScriptRenderOptions = {},
836+): ToolChunkTypeScript => {
837+ const properties: Array<readonly [string, unknown]> = [];
838+ for (const tool of tools) {
839+ if (tool.inputSchema !== undefined) {
840+ properties.push([`${tool.key}${CHUNK_INPUT_SUFFIX}`, tool.inputSchema]);
841+ }
842+ if (tool.outputSchema !== undefined) {
843+ properties.push([`${tool.key}${CHUNK_OUTPUT_SUFFIX}`, tool.outputSchema]);
844+ }
845+ }
846+ if (properties.length === 0) {
847+ return {
848+ tools: new Map(tools.map((tool) => [tool.key, {}])),
849+ definitions: {},
850+ };
851+ }
852+
853+ const wrappedSchema = buildWrappedObjectSchema(properties, defs);
854+ const source = compile(wrappedSchema, ROOT_WRAPPER_NAME, compilerOptionsFrom(options));
855+ const declarations = parseGeneratedDeclarations(source);
856+ const rootDeclaration = declarations.find(
857+ (declaration) => declaration.name === ROOT_WRAPPER_NAME,
858+ );
859+ const body = rootDeclaration?.kind === "interface" ? rootDeclaration.body : "";
860+
861+ // Single forward pass: the compiler emits root properties in insertion
862+ // order, so a running offset keeps extraction linear in the body size and
863+ // immune to one key being a substring of a later one.
864+ let offset = 0;
865+ const extractNext = (propertyName: string): string | null => {
866+ const propertyIndex = body.indexOf(propertyName, offset);
867+ if (propertyIndex < 0) {
868+ return null;
869+ }
870+ const colonIndex = body.indexOf(":", propertyIndex + propertyName.length);
871+ if (colonIndex < 0) {
872+ return null;
873+ }
874+ const end = findTypeAliasEnd(body, colonIndex + 1);
875+ if (end < 0) {
876+ return null;
877+ }
878+ offset = end;
879+ return body.slice(colonIndex + 1, end).trim();
880+ };
881+
882+ const compiled = new Map<string, ToolChunkTypeScriptEntry>();
883+ for (const tool of tools) {
884+ const input =
885+ tool.inputSchema !== undefined ? extractNext(`${tool.key}${CHUNK_INPUT_SUFFIX}`) : null;
886+ const output =
887+ tool.outputSchema !== undefined ? extractNext(`${tool.key}${CHUNK_OUTPUT_SUFFIX}`) : null;
888+ compiled.set(tool.key, {
889+ ...(input ? { inputTypeScript: compactTypeScript(input) } : {}),
890+ ...(output ? { outputTypeScript: compactTypeScript(output) } : {}),
891+ });
892+ }
893+
894+ return {
895+ tools: compiled,
896+ definitions: getDefinitionsFromDeclarations(declarations),
897+ };
898+};
899+
792900 export const buildToolTypeScriptPreview = async (input: {
793901 inputSchema?: unknown;
794902 outputSchema?: unknown;
packages/core/sdk/src/shared.ts+7−1
@@ -97,7 +97,13 @@ export { · 96 unmodified lines
9797 export type { ToolPolicyAction } from "./core-schema";
9898
9999 // Schema-side views + onboarding autodetect.
100−export { ToolSchemaView, IntegrationDetectionResult } from "./types";
100+export {
101+ ToolCatalogConnectionExport,
102+ ToolCatalogExport,
103+ ToolCatalogToolExport,
104+ ToolSchemaView,
105+ IntegrationDetectionResult,
106+} from "./types";
101107
102108 export {
103109 decodeOAuthCallbackState,
packages/core/sdk/src/typegen.test.ts+516−0
@@ -0,0 +1,516 @@
1+// ---------------------------------------------------------------------------
2+// generateToolProxySource: the `executor generate` backend.
3+//
4+// Covered here:
5+// - the generated source is valid strict TypeScript (checked with the real
6+// compiler) and its types reject wrong inputs,
7+// - the generated runtime client invokes tools through /api/executions and
8+// unwraps completed/paused/error responses (transpiled and imported, run
9+// against a fake fetch),
10+// - naming: hyphenated tool names, colliding sanitized names, description
11+// text that tries to escape its JSDoc comment,
12+// - resilience: a schema the compiler rejects degrades that one tool to
13+// `unknown` without poisoning its chunk,
14+// - `executor.tools.export` (the data source): schemas + trimmed shared
15+// $defs per connection, policy-filtered, static tools flagged.
16+// ---------------------------------------------------------------------------
17+
18+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19+import { tmpdir } from "node:os";
20+import { join } from "node:path";
21+import { pathToFileURL } from "node:url";
22+import { describe, expect, it } from "@effect/vitest";
23+import { Effect } from "effect";
24+import * as ts from "typescript";
25+
26+import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids";
27+import { definePlugin } from "./plugin";
28+import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
29+import { generateToolProxySource } from "./typegen";
30+import type { ToolCatalogExport } from "./types";
31+
32+// ---------------------------------------------------------------------------
33+// Helpers
34+// ---------------------------------------------------------------------------
35+
36+const typecheck = (source: string, extraSource = ""): readonly string[] => {
37+ const fileName = "generated.ts";
38+ const fullSource = extraSource.length > 0 ? `${source}\n${extraSource}` : source;
39+ const options: ts.CompilerOptions = {
40+ strict: true,
41+ noEmit: true,
42+ skipLibCheck: true,
43+ target: ts.ScriptTarget.ES2022,
44+ module: ts.ModuleKind.ESNext,
45+ lib: ["lib.es2022.d.ts", "lib.dom.d.ts"],
46+ };
47+ const host = ts.createCompilerHost(options);
48+ const originalGetSourceFile = host.getSourceFile.bind(host);
49+ const originalReadFile = host.readFile.bind(host);
50+ const originalFileExists = host.fileExists.bind(host);
51+ host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) =>
52+ candidate === fileName
53+ ? ts.createSourceFile(candidate, fullSource, languageVersion, true)
54+ : originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile);
55+ host.readFile = (candidate) =>
56+ candidate === fileName ? fullSource : originalReadFile(candidate);
57+ host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate);
58+
59+ const program = ts.createProgram([fileName], options, host);
60+ return ts.getPreEmitDiagnostics(program).map((diagnostic) => {
61+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
62+ const position =
63+ diagnostic.file && diagnostic.start !== undefined
64+ ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
65+ : null;
66+ return position
67+ ? `${diagnostic.file!.fileName}:${position.line + 1}:${position.character + 1} ${message}`
68+ : message;
69+ });
70+};
71+
72+/** Transpile the generated TypeScript and import it as a real module. */
73+const importGenerated = async (source: string): Promise<Record<string, unknown>> => {
74+ const transpiled = ts.transpileModule(source, {
75+ compilerOptions: {
76+ target: ts.ScriptTarget.ES2022,
77+ module: ts.ModuleKind.ESNext,
78+ },
79+ }).outputText;
80+ const dir = mkdtempSync(join(tmpdir(), "executor-typegen-"));
81+ const file = join(dir, "generated.mjs");
82+ writeFileSync(file, transpiled);
83+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: temp-dir cleanup around a dynamic import of the generated module
84+ try {
85+ return (await import(pathToFileURL(file).href)) as Record<string, unknown>;
86+ } finally {
87+ rmSync(dir, { recursive: true, force: true });
88+ }
89+};
90+
91+type ConnectionExport = ToolCatalogExport["connections"][number];
92+type ToolExport = ConnectionExport["tools"][number];
93+
94+const catalog = (connections: readonly ConnectionExport[]): ToolCatalogExport => ({ connections });
95+
96+const connectionExport = (input: {
97+ owner: "org" | "user";
98+ integration: string;
99+ connection: string;
100+ definitions?: Record<string, unknown>;
101+ tools: ReadonlyArray<{
102+ address: string;
103+ name: string;
104+ description?: string;
105+ inputSchema?: unknown;
106+ outputSchema?: unknown;
107+ static?: boolean;
108+ }>;
109+}): ConnectionExport => ({
110+ owner: input.owner,
111+ integration: IntegrationSlug.make(input.integration),
112+ connection: ConnectionName.make(input.connection),
113+ ...(input.definitions !== undefined ? { definitions: input.definitions } : {}),
114+ tools: input.tools.map(
115+ (tool): ToolExport => ({
116+ ...tool,
117+ address: ToolAddress.make(tool.address),
118+ }),
119+ ),
120+});
121+
122+const githubConnection = connectionExport({
123+ owner: "org",
124+ integration: "github",
125+ connection: "main",
126+ definitions: {
127+ User: {
128+ type: "object",
129+ properties: { id: { type: "string" }, login: { type: "string" } },
130+ required: ["id"],
131+ },
132+ },
133+ tools: [
134+ {
135+ address: "tools.github.org.main.issues.create",
136+ name: "issues.create",
137+ description: "Create an issue",
138+ inputSchema: {
139+ type: "object",
140+ properties: { title: { type: "string" }, assignee: { $ref: "#/$defs/User" } },
141+ required: ["title"],
142+ },
143+ outputSchema: {
144+ type: "object",
145+ properties: { number: { type: "number" }, user: { $ref: "#/$defs/User" } },
146+ },
147+ },
148+ {
149+ address: "tools.github.org.main.issues.list",
150+ name: "issues.list",
151+ description: "List issues */ } escape attempt",
152+ inputSchema: {
153+ type: "object",
154+ properties: { state: { enum: ["open", "closed"] } },
155+ },
156+ },
157+ {
158+ address: "tools.github.org.main.repos.get",
159+ name: "repos.get",
160+ description: "No schemas at all",
161+ },
162+ ],
163+});
164+
165+// ---------------------------------------------------------------------------
166+// Generation
167+// ---------------------------------------------------------------------------
168+
169+describe("generateToolProxySource", { timeout: 60_000 }, () => {
170+ it("emits strict-TypeScript-valid source with per-tool types", () => {
171+ const generated = generateToolProxySource(catalog([githubConnection]));
172+ expect(generated.toolCount).toBe(3);
173+ expect(generated.connectionCount).toBe(1);
174+
175+ const diagnostics = typecheck(generated.source);
176+ expect(diagnostics).toEqual([]);
177+
178+ expect(generated.source).toContain("export interface ExecutorTools");
179+ expect(generated.source).toContain("issues_create_Input");
180+ // Shared $defs become named, reused types instead of inlined copies.
181+ expect(generated.source).toContain("export type User =");
182+ });
183+
184+ it("generated types accept correct calls and reject wrong ones", () => {
185+ const generated = generateToolProxySource(catalog([githubConnection]));
186+
187+ const validConsumer = `
188+ const client = createExecutorClient({ baseUrl: "http://localhost:4788" });
189+ async function main() {
190+ const created = await client.github.org.main.issues.create({ title: "hi" });
191+ if (created.ok) {
192+ const n: number | undefined = created.data.number;
193+ void n;
194+ } else {
195+ const code: string = created.error.code;
196+ void code;
197+ }
198+ await client.github.org.main.repos.get();
199+ await client.$call("github.org.main.repos.get", {});
200+ }
201+ void main;
202+ `;
203+ expect(typecheck(generated.source, validConsumer)).toEqual([]);
204+
205+ const invalidConsumer = `
206+ const client = createExecutorClient();
207+ async function main() {
208+ // title is required and must be a string
209+ await client.github.org.main.issues.create({ title: 42 });
210+ }
211+ void main;
212+ `;
213+ expect(typecheck(generated.source, invalidConsumer)).not.toEqual([]);
214+ });
215+
216+ it("keeps hyphenated path segments callable and dedupes colliding type names", () => {
217+ const generated = generateToolProxySource(
218+ catalog([
219+ connectionExport({
220+ owner: "user",
221+ integration: "linear",
222+ connection: "personal",
223+ tools: [
224+ {
225+ address: "tools.linear.user.personal.issue-create",
226+ name: "issue-create",
227+ inputSchema: { type: "object", properties: { title: { type: "string" } } },
228+ },
229+ {
230+ // Sanitizes to the same identifier as issue-create.
231+ address: "tools.linear.user.personal.issue_create",
232+ name: "issue_create",
233+ inputSchema: { type: "object", properties: { key: { type: "string" } } },
234+ },
235+ ],
236+ }),
237+ ]),
238+ );
239+
240+ expect(typecheck(generated.source)).toEqual([]);
241+ expect(generated.source).toContain('"issue-create":');
242+ // Both tools keep distinct input types despite the name collision.
243+ expect(generated.source).toContain("issue_create_Input");
244+ expect(generated.source).toContain("issue_create_Input_2");
245+ });
246+
247+ it("degrades a compiler-rejected schema to unknown without poisoning its chunk", () => {
248+ const generated = generateToolProxySource(
249+ catalog([
250+ connectionExport({
251+ owner: "org",
252+ integration: "demo",
253+ connection: "main",
254+ tools: [
255+ {
256+ address: "tools.demo.org.main.good",
257+ name: "good",
258+ inputSchema: { type: "object", properties: { ok: { type: "boolean" } } },
259+ },
260+ {
261+ address: "tools.demo.org.main.broken",
262+ name: "broken",
263+ // $ref to a definition that does not exist: the vendored
264+ // compiler throws on dangling refs.
265+ inputSchema: { $ref: "#/$defs/DoesNotExist" },
266+ },
267+ ],
268+ }),
269+ ]),
270+ { chunkSize: 50 },
271+ );
272+
273+ expect(typecheck(generated.source)).toEqual([]);
274+ // The good tool keeps its real type; the broken one degrades to unknown.
275+ expect(generated.source).toContain("good_Input = { ok?: boolean; }");
276+ expect(generated.source).toContain("broken_Input = unknown");
277+ });
278+
279+ it("returns an empty interface for an empty catalog", () => {
280+ const generated = generateToolProxySource(catalog([]));
281+ expect(generated.toolCount).toBe(0);
282+ expect(typecheck(generated.source)).toEqual([]);
283+ });
284+});
285+
286+// ---------------------------------------------------------------------------
287+// Generated runtime client
288+// ---------------------------------------------------------------------------
289+
290+type FetchCall = { url: string; init: { headers: Record<string, string>; body: string } };
291+
292+const makeFakeFetch = (respond: (call: FetchCall) => unknown) => {
293+ const calls: FetchCall[] = [];
294+ const fetchImpl: typeof globalThis.fetch = async (url, init) => {
295+ const call: FetchCall = {
296+ url: String(url),
297+ init: {
298+ headers: (init?.headers ?? {}) as Record<string, string>,
299+ body: String(init?.body ?? ""),
300+ },
301+ };
302+ calls.push(call);
303+ return new Response(JSON.stringify(respond(call)), { status: 200 });
304+ };
305+ return { calls, fetchImpl };
306+};
307+
308+describe("generated runtime client", { timeout: 60_000 }, () => {
309+ it("invokes tools through /api/executions and unwraps the outcome", async () => {
310+ const generated = generateToolProxySource(catalog([githubConnection]));
311+ const module = await importGenerated(generated.source);
312+ const createClient = module.createExecutorClient as (
313+ options: Record<string, unknown>,
314+ ) => unknown;
315+
316+ const { calls, fetchImpl } = makeFakeFetch(() => ({
317+ status: "completed",
318+ text: "",
319+ isError: false,
320+ structured: { result: { ok: true, data: { number: 7 } } },
321+ }));
322+
323+ // 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
324+ const client = createClient({
325+ baseUrl: "http://example.test:4788/",
326+ token: "tok_123",
327+ fetch: fetchImpl,
328+ }) as unknown as {
329+ github: {
330+ org: {
331+ main: {
332+ issues: { create: (input: unknown) => Promise<{ ok: boolean; data?: unknown }> };
333+ };
334+ };
335+ };
336+ };
337+
338+ const outcome = await client.github.org.main.issues.create({ title: "hi" });
339+ expect(outcome).toEqual({ ok: true, data: { number: 7 } });
340+
341+ expect(calls).toHaveLength(1);
342+ expect(calls[0]!.url).toBe("http://example.test:4788/api/executions");
343+ expect(calls[0]!.init.headers.authorization).toBe("Bearer tok_123");
344+ // oxlint-disable-next-line executor/no-json-parse -- test boundary: asserting on the raw request body the generated client produced
345+ const body = JSON.parse(calls[0]!.init.body) as { code: string };
346+ expect(body.code).toContain('["github"]["org"]["main"]["issues"]["create"]');
347+ expect(body.code).toContain('{"title":"hi"}');
348+ });
349+
350+ it("throws ExecutorPausedError with the approval url on paused executions", async () => {
351+ const generated = generateToolProxySource(catalog([githubConnection]));
352+ const module = await importGenerated(generated.source);
353+ const createClient = module.createExecutorClient as (options: Record<string, unknown>) => {
354+ $call: (path: string, input?: unknown) => Promise<unknown>;
355+ };
356+
357+ const { fetchImpl } = makeFakeFetch(() => ({
358+ status: "paused",
359+ text: "Approval required",
360+ structured: { executionId: "exec_42" },
361+ }));
362+
363+ const client = createClient({ baseUrl: "http://example.test:4788", fetch: fetchImpl });
364+ const failure = await client.$call("github.org.main.issues.create", { title: "hi" }).then(
365+ () => null,
366+ (error: unknown) => error as Record<string, unknown>,
367+ );
368+
369+ expect(failure).not.toBeNull();
370+ expect(failure!.name).toBe("ExecutorPausedError");
371+ expect(failure!.executionId).toBe("exec_42");
372+ expect(failure!.approvalUrl).toBe("http://example.test:4788/resume/exec_42");
373+ });
374+
375+ it("rejects path segments that could break out of the invoke code", async () => {
376+ const generated = generateToolProxySource(catalog([githubConnection]));
377+ const module = await importGenerated(generated.source);
378+ const createClient = module.createExecutorClient as (options: Record<string, unknown>) => {
379+ $call: (path: string, input?: unknown) => Promise<unknown>;
380+ };
381+
382+ const { calls, fetchImpl } = makeFakeFetch(() => ({}));
383+ const client = createClient({ fetch: fetchImpl });
384+
385+ await expect(client.$call('bad"segment];evil()', {})).rejects.toMatchObject({
386+ name: "ExecutorRequestError",
387+ });
388+ expect(calls).toHaveLength(0);
389+ });
390+});
391+
392+// ---------------------------------------------------------------------------
393+// executor.tools.export: the catalog read behind the CLI
394+// ---------------------------------------------------------------------------
395+
396+const INTEG = IntegrationSlug.make("demo");
397+const CONN = ConnectionName.make("main");
398+const TEMPLATE = AuthTemplateSlug.make("apiKey");
399+
400+const demoPlugin = definePlugin(() => ({
401+ id: "demo" as const,
402+ storage: () => ({}),
403+ resolveTools: () =>
404+ Effect.succeed({
405+ tools: [
406+ {
407+ name: ToolName.make("inspect"),
408+ description: "inspect",
409+ inputSchema: {
410+ type: "object",
411+ properties: { pet: { $ref: "#/$defs/Pet" } },
412+ required: ["pet"],
413+ },
414+ outputSchema: { $ref: "#/$defs/Owner" },
415+ },
416+ { name: ToolName.make("run"), description: "run" },
417+ ],
418+ definitions: {
419+ Pet: { type: "object", properties: { name: { type: "string" } } },
420+ Owner: { type: "object", properties: { pet: { $ref: "#/$defs/Pet" } } },
421+ Unreferenced: { type: "object", properties: { value: { type: "string" } } },
422+ },
423+ }),
424+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
425+ extension: (ctx) => ({
426+ seed: () =>
427+ ctx.core.integrations.register({
428+ slug: INTEG,
429+ description: "Demo",
430+ config: {},
431+ }),
432+ }),
433+}))();
434+
435+const setup = () =>
436+ Effect.gen(function* () {
437+ const executor = yield* makeTestExecutor({
438+ plugins: [memoryCredentialsPlugin(), demoPlugin] as const,
439+ });
440+ yield* executor.demo.seed();
441+ yield* executor.connections.create({
442+ owner: "org",
443+ name: CONN,
444+ integration: INTEG,
445+ template: TEMPLATE,
446+ value: "token",
447+ });
448+ return executor;
449+ });
450+
451+describe("tools.export", { timeout: 60_000 }, () => {
452+ it.effect("returns schemas grouped per connection with trimmed shared $defs", () =>
453+ Effect.gen(function* () {
454+ const executor = yield* setup();
455+ const exported = yield* executor.tools.export({ integration: INTEG });
456+
457+ expect(exported.connections).toHaveLength(1);
458+ const connection = exported.connections[0]!;
459+ expect(String(connection.integration)).toBe("demo");
460+ expect(String(connection.connection)).toBe("main");
461+ expect(connection.tools.map((tool) => tool.name).sort()).toEqual(["inspect", "run"]);
462+
463+ const inspect = connection.tools.find((tool) => tool.name === "inspect")!;
464+ expect(inspect.inputSchema).toMatchObject({ type: "object" });
465+ expect(inspect.outputSchema).toEqual({ $ref: "#/$defs/Owner" });
466+
467+ // Referenced defs (Pet transitively via Owner) come along; unreferenced
468+ // ones are trimmed.
469+ expect(Object.keys(connection.definitions ?? {}).sort()).toEqual(["Owner", "Pet"]);
470+ }),
471+ );
472+
473+ it.effect("omits blocked tools like tools.list does", () =>
474+ Effect.gen(function* () {
475+ const executor = yield* setup();
476+ yield* executor.policies.create({
477+ owner: "org",
478+ pattern: "demo.org.main.run",
479+ action: "block",
480+ });
481+
482+ const exported = yield* executor.tools.export({ integration: INTEG });
483+ const names = exported.connections.flatMap((connection) =>
484+ connection.tools.map((tool) => tool.name),
485+ );
486+ expect(names).toEqual(["inspect"]);
487+ }),
488+ );
489+
490+ it.effect("flags static tools and feeds a generatable catalog end to end", () =>
491+ Effect.gen(function* () {
492+ const executor = yield* makeTestExecutor({
493+ plugins: [memoryCredentialsPlugin(), demoPlugin] as const,
494+ coreTools: { webBaseUrl: "http://localhost:3000" },
495+ });
496+ yield* executor.demo.seed();
497+ yield* executor.connections.create({
498+ owner: "org",
499+ name: CONN,
500+ integration: INTEG,
501+ template: TEMPLATE,
502+ value: "token",
503+ });
504+ const exported = yield* executor.tools.export({ includeBlocked: true });
505+
506+ const staticTools = exported.connections
507+ .flatMap((connection) => connection.tools)
508+ .filter((tool) => tool.static === true);
509+ expect(staticTools.length).toBeGreaterThan(0);
510+
511+ const generated = generateToolProxySource(exported);
512+ expect(generated.toolCount).toBeGreaterThanOrEqual(2);
513+ expect(typecheck(generated.source)).toEqual([]);
514+ }),
515+ );
516+});
packages/core/sdk/src/typegen.ts+528−0
@@ -0,0 +1,528 @@
1+// ---------------------------------------------------------------------------
2+// Typed tool proxy generation: the `executor generate` backend.
3+//
4+// Turns a `ToolCatalogExport` (every visible tool's input/output JSON schema,
5+// grouped per connection with the connection's shared `$defs`) into ONE
6+// self-contained TypeScript source file:
7+//
8+// - a dependency-free runtime client (Proxy-based, so its size is constant
9+// no matter how many tools the catalog holds) that invokes tools through
10+// the server's `/api/executions` endpoint,
11+// - one non-instantiated (type-only, erasable) namespace per connection
12+// carrying that connection's shared definitions and per-tool input/output
13+// types,
14+// - an `ExecutorTools` interface mirroring the sandbox `tools.*` path tree,
15+// so `client.github.org.main.issues.create({...})` is fully typed.
16+//
17+// Scale is a first-class constraint: catalogs reach 10k+ tools. Schemas are
18+// compiled through `compileToolChunkTypeScript` (many tools per compiler pass;
19+// one pass per `chunkSize` tools) because per-tool passes pay fixed overhead
20+// 10k times and one whole-catalog pass grows super-linearly with declaration
21+// count. All source assembly appends to arrays and joins once.
22+// ---------------------------------------------------------------------------
23+
24+import {
25+ compileToolChunkTypeScript,
26+ type ToolChunkSchemaEntry,
27+ type ToolChunkTypeScriptEntry,
28+} from "./schema-types";
29+import type { ToolCatalogConnectionExport, ToolCatalogExport } from "./types";
30+
31+export type GenerateToolProxyOptions = {
32+ /** Tools compiled per compiler pass. The pass cost grows super-linearly
33+ * with declaration count, so keep chunks small; 200 keeps each pass tens
34+ * of milliseconds while amortizing per-pass overhead. */
35+ readonly chunkSize?: number;
36+ /** Client factory name in the generated file. */
37+ readonly clientName?: string;
38+};
39+
40+const DEFAULT_CHUNK_SIZE = 200;
41+
42+// ---------------------------------------------------------------------------
43+// Identifier helpers
44+// ---------------------------------------------------------------------------
45+
46+const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
47+
48+const sanitizeIdentifier = (value: string): string => {
49+ const cleaned = value.replace(/[^A-Za-z0-9_$]/g, "_");
50+ return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned.length > 0 ? cleaned : "_";
51+};
52+
53+const propertyKey = (segment: string): string =>
54+ IDENTIFIER_PATTERN.test(segment) ? segment : JSON.stringify(segment);
55+
56+const uniqueName = (base: string, used: Set<string>): string => {
57+ let candidate = base;
58+ let counter = 2;
59+ while (used.has(candidate)) {
60+ candidate = `${base}_${counter}`;
61+ counter += 1;
62+ }
63+ used.add(candidate);
64+ return candidate;
65+};
66+
67+const escapeJsDoc = (value: string): string => value.replace(/\*\//g, "*\\/");
68+
69+// ---------------------------------------------------------------------------
70+// Per-connection compilation
71+// ---------------------------------------------------------------------------
72+
73+type CompiledTool = {
74+ readonly pathSegments: readonly string[];
75+ readonly description?: string;
76+ /** Fully-qualified type reference (`Ns.Name_Input`) or undefined when the
77+ * tool has no input schema (callable with no argument). */
78+ readonly inputTypeRef?: string;
79+ /** Fully-qualified type reference or "unknown". */
80+ readonly outputTypeRef: string;
81+};
82+
83+type CompiledConnection = {
84+ readonly namespaceName: string;
85+ /** `export type Name = body;` lines inside the namespace. */
86+ readonly declarations: readonly string[];
87+ readonly tools: readonly CompiledTool[];
88+};
89+
90+/** Sandbox `tools.*` path for a tool: dynamic addresses drop the `tools.`
91+ * proxy-root prefix; static addresses are already the callable path. */
92+const toolPathSegments = (
93+ connection: ToolCatalogConnectionExport,
94+ tool: ToolCatalogConnectionExport["tools"][number],
95+): readonly string[] => {
96+ const address = String(tool.address);
97+ const path = address.startsWith("tools.") ? address.slice("tools.".length) : address;
98+ return path.split(".");
99+};
100+
101+const compileConnection = (
102+ connection: ToolCatalogConnectionExport,
103+ namespaceName: string,
104+ chunkSize: number,
105+): CompiledConnection => {
106+ const defs = new Map<string, unknown>(Object.entries(connection.definitions ?? {}));
107+ const tools = [...connection.tools].sort((a, b) =>
108+ String(a.address).localeCompare(String(b.address)),
109+ );
110+
111+ // Chunked compile: keys are positional (`t<index>`), unique and identifier-
112+ // safe by construction. A failing chunk retries tool-by-tool so one broken
113+ // schema degrades that tool to `unknown` instead of the whole chunk.
114+ const compiled = new Map<string, ToolChunkTypeScriptEntry>();
115+ const definitionDecls = new Map<string, string>();
116+ const entries: ToolChunkSchemaEntry[] = tools.map((tool, index) => ({
117+ key: `t${index}`,
118+ ...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
119+ ...(tool.outputSchema !== undefined ? { outputSchema: tool.outputSchema } : {}),
120+ }));
121+
122+ const absorb = (result: ReturnType<typeof compileToolChunkTypeScript>) => {
123+ for (const [key, value] of result.tools) {
124+ compiled.set(key, value);
125+ }
126+ for (const [name, body] of Object.entries(result.definitions)) {
127+ if (!definitionDecls.has(name)) {
128+ definitionDecls.set(name, body);
129+ }
130+ }
131+ };
132+
133+ for (let start = 0; start < entries.length; start += chunkSize) {
134+ const chunk = entries.slice(start, start + chunkSize);
135+ // 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
136+ try {
137+ absorb(compileToolChunkTypeScript(chunk, defs));
138+ } catch {
139+ for (const entry of chunk) {
140+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: same throwing compiler; a single broken schema degrades to unknown
141+ try {
142+ absorb(compileToolChunkTypeScript([entry], defs));
143+ } catch {
144+ compiled.set(entry.key, {
145+ ...(entry.inputSchema !== undefined ? { inputTypeScript: "unknown" } : {}),
146+ ...(entry.outputSchema !== undefined ? { outputTypeScript: "unknown" } : {}),
147+ });
148+ }
149+ }
150+ }
151+ }
152+
153+ // Name per-tool types after the tool, avoiding collisions with definition
154+ // names (which the compiled type bodies reference bare) and each other.
155+ const usedNames = new Set<string>(definitionDecls.keys());
156+ const declarations: string[] = [];
157+ for (const [name, body] of [...definitionDecls.entries()].sort(([a], [b]) =>
158+ a.localeCompare(b),
159+ )) {
160+ declarations.push(` export type ${name} = ${body};`);
161+ }
162+
163+ const compiledTools: CompiledTool[] = tools.map((tool, index) => {
164+ const entry = compiled.get(`t${index}`) ?? {};
165+ const base = sanitizeIdentifier(tool.name);
166+ let inputTypeRef: string | undefined;
167+ if (entry.inputTypeScript !== undefined) {
168+ const inputName = uniqueName(`${base}_Input`, usedNames);
169+ declarations.push(` export type ${inputName} = ${entry.inputTypeScript};`);
170+ inputTypeRef = `${namespaceName}.${inputName}`;
171+ }
172+ let outputTypeRef = "unknown";
173+ if (entry.outputTypeScript !== undefined) {
174+ const outputName = uniqueName(`${base}_Output`, usedNames);
175+ declarations.push(` export type ${outputName} = ${entry.outputTypeScript};`);
176+ outputTypeRef = `${namespaceName}.${outputName}`;
177+ }
178+ return {
179+ pathSegments: toolPathSegments(connection, tool),
180+ ...(tool.description !== undefined && tool.description.length > 0
181+ ? { description: tool.description }
182+ : {}),
183+ ...(inputTypeRef !== undefined ? { inputTypeRef } : {}),
184+ outputTypeRef,
185+ };
186+ });
187+
188+ return { namespaceName, declarations, tools: compiledTools };
189+};
190+
191+// ---------------------------------------------------------------------------
192+// Path tree
193+// ---------------------------------------------------------------------------
194+
195+type TreeNode = {
196+ children: Map<string, TreeNode>;
197+ leaf?: CompiledTool;
198+};
199+
200+const insertTool = (root: TreeNode, tool: CompiledTool): void => {
201+ let node = root;
202+ for (const segment of tool.pathSegments) {
203+ let child = node.children.get(segment);
204+ if (!child) {
205+ child = { children: new Map() };
206+ node.children.set(segment, child);
207+ }
208+ node = child;
209+ }
210+ node.leaf = tool;
211+};
212+
213+const leafType = (tool: CompiledTool): string =>
214+ tool.inputTypeRef !== undefined
215+ ? `ExecutorToolFn<${tool.inputTypeRef}, ${tool.outputTypeRef}>`
216+ : `ExecutorToolFnNoInput<${tool.outputTypeRef}>`;
217+
218+const emitNode = (node: TreeNode, indent: string, out: string[]): void => {
219+ // A node can be both callable (a tool lives at this exact path) and a
220+ // container (deeper tools share the prefix); emit the intersection.
221+ const segments = [...node.children.entries()].sort(([a], [b]) => a.localeCompare(b));
222+ if (node.leaf) {
223+ out.push(leafType(node.leaf));
224+ if (segments.length > 0) {
225+ out.push(" & ");
226+ }
227+ }
228+ if (segments.length === 0) {
229+ return;
230+ }
231+ out.push("{\n");
232+ for (const [segment, child] of segments) {
233+ if (child.leaf?.description !== undefined) {
234+ out.push(`${indent} /** ${escapeJsDoc(child.leaf.description)} */\n`);
235+ }
236+ out.push(`${indent} ${propertyKey(segment)}: `);
237+ emitNode(child, `${indent} `, out);
238+ out.push(";\n");
239+ }
240+ out.push(`${indent}}`);
241+};
242+
243+// ---------------------------------------------------------------------------
244+// Runtime template: embedded verbatim in the generated file so the output
245+// has zero dependencies. Keep this plain TypeScript: no imports, no
246+// namespaces with runtime meaning, nothing beyond ES2020 + fetch.
247+// ---------------------------------------------------------------------------
248+
249+const runtimeTemplate = (clientName: string): string => `\
250+export type ExecutorToolError = {
251+ code: string;
252+ message: string;
253+ status?: number;
254+ details?: unknown;
255+ retryable?: boolean;
256+};
257+export type ExecutorToolHttpMeta = { status: number; headers: { [k: string]: string } };
258+export type ExecutorToolOutcome<O> =
259+ | { ok: true; data: O; http?: ExecutorToolHttpMeta }
260+ | { ok: false; error: ExecutorToolError };
261+export interface ExecutorCallOptions {
262+ /** Approve approval-gated tools as the caller instead of pausing. */
263+ autoApprove?: boolean;
264+ signal?: AbortSignal;
265+}
266+export type ExecutorToolFn<I, O> = (
267+ input: I,
268+ options?: ExecutorCallOptions,
269+) => Promise<ExecutorToolOutcome<O>>;
270+export type ExecutorToolFnNoInput<O> = (
271+ input?: Record<string, unknown>,
272+ options?: ExecutorCallOptions,
273+) => Promise<ExecutorToolOutcome<O>>;
274+
275+export interface ExecutorClientOptions {
276+ /** Executor server origin, e.g. "http://localhost:4788". */
277+ baseUrl?: string;
278+ /** Bearer token. Defaults to EXECUTOR_API_KEY / EXECUTOR_AUTH_TOKEN. */
279+ token?: string;
280+ fetch?: typeof globalThis.fetch;
281+ headers?: Record<string, string>;
282+ /** Approve approval-gated tools for every call from this client. */
283+ autoApprove?: boolean;
284+}
285+
286+export class ExecutorRequestError extends Error {
287+ readonly status: number | null;
288+ readonly body: string;
289+ constructor(message: string, status: number | null, body: string) {
290+ super(message);
291+ this.name = "ExecutorRequestError";
292+ this.status = status;
293+ this.body = body;
294+ }
295+}
296+
297+export class ExecutorPausedError extends Error {
298+ readonly executionId: string | null;
299+ readonly approvalUrl: string | null;
300+ constructor(message: string, executionId: string | null, approvalUrl: string | null) {
301+ super(message);
302+ this.name = "ExecutorPausedError";
303+ this.executionId = executionId;
304+ this.approvalUrl = approvalUrl;
305+ }
306+}
307+
308+const DEFAULT_BASE_URL = "http://localhost:4788";
309+const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
310+
311+const readEnvToken = (): string | undefined => {
312+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
313+ ?.env;
314+ return env?.EXECUTOR_API_KEY ?? env?.EXECUTOR_AUTH_TOKEN;
315+};
316+
317+const isRecord = (value: unknown): value is Record<string, unknown> =>
318+ typeof value === "object" && value !== null && !Array.isArray(value);
319+
320+const buildInvokeCode = (segments: readonly string[], input: unknown): string => {
321+ const access = segments.map((segment) => \`[\${JSON.stringify(segment)}]\`).join("");
322+ return [
323+ \`const __args = \${JSON.stringify(input ?? {})};\`,
324+ \`const __target = tools\${access};\`,
325+ \`if (typeof __target !== "function") {\`,
326+ \` throw new Error(\${JSON.stringify(\`Tool not found: \${segments.join(".")}\`)});\`,
327+ \`}\`,
328+ \`return await __target(__args);\`,
329+ ].join("\\n");
330+};
331+
332+export interface ExecutorClientHandle {
333+ /** Invoke any tool by dotted path, untyped. */
334+ $call: (
335+ path: string,
336+ input?: Record<string, unknown>,
337+ options?: ExecutorCallOptions,
338+ ) => Promise<ExecutorToolOutcome<unknown>>;
339+}
340+
341+export type ExecutorClient = ExecutorTools & ExecutorClientHandle;
342+
343+export function ${clientName}(options: ExecutorClientOptions = {}): ExecutorClient {
344+ const origin = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, "");
345+ const token = options.token ?? readEnvToken();
346+ const fetchImpl = options.fetch ?? globalThis.fetch;
347+
348+ const invoke = async (
349+ segments: readonly string[],
350+ input: unknown,
351+ callOptions?: ExecutorCallOptions,
352+ ): Promise<ExecutorToolOutcome<unknown>> => {
353+ for (const segment of segments) {
354+ if (!PATH_SEGMENT_PATTERN.test(segment)) {
355+ throw new ExecutorRequestError(\`Invalid tool path segment: \${segment}\`, null, "");
356+ }
357+ }
358+ if (input !== undefined && !isRecord(input)) {
359+ throw new ExecutorRequestError("Tool input must be a JSON object", null, "");
360+ }
361+
362+ const autoApprove = callOptions?.autoApprove ?? options.autoApprove;
363+ const response = await fetchImpl(\`\${origin}/api/executions\`, {
364+ method: "POST",
365+ headers: {
366+ "content-type": "application/json",
367+ ...(token ? { authorization: \`Bearer \${token}\` }: {}),
368+ ...options.headers,
369+ },
370+ body: JSON.stringify({
371+ code: buildInvokeCode(segments, input),
372+ ...(autoApprove !== undefined ? { autoApprove }: {}),
373+ }),
374+ ...(callOptions?.signal ? { signal: callOptions.signal }: {}),
375+ });
376+
377+ const bodyText = await response.text();
378+ if (!response.ok) {
379+ throw new ExecutorRequestError(
380+ \`Executor request failed with status \${response.status}\`,
381+ response.status,
382+ bodyText,
383+ );
384+ }
385+
386+ let payload: unknown;
387+ try {
388+ payload = JSON.parse(bodyText);
389+ } catch {
390+ throw new ExecutorRequestError("Executor returned a non-JSON response", null, bodyText);
391+ }
392+ if (!isRecord(payload)) {
393+ throw new ExecutorRequestError("Executor returned an unexpected response", null, bodyText);
394+ }
395+
396+ if (payload.status === "paused") {
397+ const structured = isRecord(payload.structured) ? payload.structured: {};
398+ const executionId =
399+ typeof structured.executionId === "string" ? structured.executionId: null;
400+ throw new ExecutorPausedError(
401+ typeof payload.text === "string" ? payload.text: "Execution paused awaiting approval",
402+ executionId,
403+ executionId ? \`\${origin}/resume/\${encodeURIComponent(executionId)}\`: null,
404+ );
405+ }
406+
407+ if (payload.isError === true) {
408+ throw new ExecutorRequestError(
409+ typeof payload.text === "string" ? payload.text: "Tool execution failed",
410+ null,
411+ bodyText,
412+ );
413+ }
414+
415+ const structured = isRecord(payload.structured) ? payload.structured: {};
416+ const result = structured.result;
417+ // Dynamic tool results already arrive in the outcome envelope; anything
418+ // else (static tools returning raw values) is wrapped as a success.
419+ if (isRecord(result) && typeof result.ok === "boolean") {
420+ return result as ExecutorToolOutcome<unknown>;
421+ }
422+ return { ok: true, data: result };
423+ };
424+
425+ const callByPath = (
426+ path: string,
427+ input?: Record<string, unknown>,
428+ callOptions?: ExecutorCallOptions,
429+ ) => invoke(path.split("."), input, callOptions);
430+
431+ const makeNode = (segments: readonly string[]): unknown =>
432+ new Proxy(() => undefined, {
433+ get: (_target, property) => {
434+ if (segments.length === 0 && property === "$call") {
435+ return callByPath;
436+ }
437+ if (typeof property !== "string") {
438+ return undefined;
439+ }
440+ return makeNode([...segments, property]);
441+ },
442+ apply: (_target, _thisArg, args: unknown[]) => invoke(segments, args[0], args[1] as ExecutorCallOptions | undefined),
443+ });
444+
445+ return makeNode([]) as ExecutorClient;
446+}
447+`;
448+
449+// ---------------------------------------------------------------------------
450+// Entry point
451+// ---------------------------------------------------------------------------
452+
453+export type GeneratedToolProxy = {
454+ readonly source: string;
455+ readonly toolCount: number;
456+ readonly connectionCount: number;
457+};
458+
459+export const generateToolProxySource = (
460+ catalog: ToolCatalogExport,
461+ options: GenerateToolProxyOptions = {},
462+): GeneratedToolProxy => {
463+ const chunkSize = Math.max(1, options.chunkSize ?? DEFAULT_CHUNK_SIZE);
464+ const clientName = options.clientName ?? "createExecutorClient";
465+
466+ const connections = [...catalog.connections].sort((a, b) => {
467+ const left = `${a.integration}${a.owner}${a.connection}`;
468+ const right = `${b.integration}${b.owner}${b.connection}`;
469+ return left.localeCompare(right);
470+ });
471+
472+ const usedNamespaces = new Set<string>();
473+ const compiledConnections = connections.map((connection) =>
474+ compileConnection(
475+ connection,
476+ uniqueName(
477+ `Executor_${sanitizeIdentifier(`${connection.integration}_${connection.owner}_${connection.connection}`)}`,
478+ usedNamespaces,
479+ ),
480+ chunkSize,
481+ ),
482+ );
483+
484+ const root: TreeNode = { children: new Map() };
485+ let toolCount = 0;
486+ for (const connection of compiledConnections) {
487+ for (const tool of connection.tools) {
488+ insertTool(root, tool);
489+ toolCount += 1;
490+ }
491+ }
492+
493+ const out: string[] = [];
494+ out.push(
495+ "// Generated by `executor generate`. Do not edit.\n",
496+ "// Regenerate with: executor generate\n",
497+ "/* eslint-disable */\n",
498+ "\n",
499+ );
500+ out.push(runtimeTemplate(clientName));
501+ out.push("\n");
502+
503+ for (const connection of compiledConnections) {
504+ if (connection.declarations.length === 0) {
505+ continue;
506+ }
507+ // Type-only (non-instantiated) namespace: erased at compile time, so the
508+ // generated file stays valid under erasableSyntaxOnly and isolatedModules.
509+ out.push(`export namespace ${connection.namespaceName} {\n`);
510+ for (const declaration of connection.declarations) {
511+ out.push(declaration, "\n");
512+ }
513+ out.push("}\n\n");
514+ }
515+
516+ out.push("export interface ExecutorTools ");
517+ const treeOut: string[] = [];
518+ emitNode(root, "", treeOut);
519+ // The root is never callable; emitNode on an empty tree emits nothing.
520+ out.push(treeOut.length > 0 ? treeOut.join("") : "{\n}");
521+ out.push("\n");
522+
523+ return {
524+ source: out.join(""),
525+ toolCount,
526+ connectionCount: compiledConnections.length,
527+ };
528+};
packages/core/sdk/src/types.ts+36−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
77
88 import { Schema } from "effect";
99
10−import { ToolAddress } from "./ids";
10+import { ConnectionName, IntegrationSlug, Owner, ToolAddress } from "./ids";
1111
1212 // ---------------------------------------------------------------------------
1313 // ToolSchemaView — the full schema-side view of a tool, returned by
@@ -28,6 +28,41 @@ export const ToolSchemaView = Schema.Struct({ · 14 unmodified lines
2828 });
2929 export type ToolSchemaView = typeof ToolSchemaView.Type;
3030
31+// ---------------------------------------------------------------------------
32+// ToolCatalogExport — the bulk schema-bearing read returned by
33+// `executor.tools.export(filter)`. Unlike `tools.list` (metadata-only, schemas
34+// deliberately projected out) this carries every tool's input/output JSON
35+// schema plus the connection's shared `$defs`, grouped per connection so a
36+// consumer (the `executor generate` typegen) can compile self-contained
37+// TypeScript without one `tools.schema` round trip per tool.
38+// ---------------------------------------------------------------------------
39+
40+export const ToolCatalogToolExport = Schema.Struct({
41+ address: ToolAddress,
42+ name: Schema.String,
43+ description: Schema.optional(Schema.String),
44+ inputSchema: Schema.optional(Schema.Unknown),
45+ outputSchema: Schema.optional(Schema.Unknown),
46+ static: Schema.optional(Schema.Boolean),
47+});
48+export type ToolCatalogToolExport = typeof ToolCatalogToolExport.Type;
49+
50+export const ToolCatalogConnectionExport = Schema.Struct({
51+ owner: Owner,
52+ integration: IntegrationSlug,
53+ connection: ConnectionName,
54+ tools: Schema.Array(ToolCatalogToolExport),
55+ /** Shared `$defs` referenced by this connection's tool schemas, already
56+ * trimmed to the referenced subset. */
57+ definitions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
58+});
59+export type ToolCatalogConnectionExport = typeof ToolCatalogConnectionExport.Type;
60+
61+export const ToolCatalogExport = Schema.Struct({
62+ connections: Schema.Array(ToolCatalogConnectionExport),
63+});
64+export type ToolCatalogExport = typeof ToolCatalogExport.Type;
65+
3166 // ---------------------------------------------------------------------------
3267 // Integration detection — optional capability on `PluginSpec.detect`. When a
3368 // user pastes a URL in the onboarding UI, `executor.integrations.detect(url)`
packages/plugins/openapi/src/sdk/typegen-scale.test.ts+213−0
@@ -0,0 +1,213 @@
1+// ---------------------------------------------------------------------------
2+// `executor generate` at catalog scale: 10,000 tools from one OpenAPI spec.
3+//
4+// The typed-proxy pipeline must hold up on instances with five-digit tool
5+// counts, end to end through the REAL ingestion path (addSpec compiles the
6+// spec, a connection persists the tool rows) rather than hand-built fixtures:
7+// - `tools.export` returns every tool in one read,
8+// - `generateToolProxySource` emits the file without blowing time or memory
9+// (chunked schema compilation: one whole-catalog compiler pass is
10+// super-linear and takes 30s+ at this size),
11+// - the generated source is valid strict TypeScript, verified with the real
12+// compiler, and spot-checked types resolve to the right shapes.
13+//
14+// Budgets are deliberately loose (CI machines vary) but tight enough to catch
15+// a regression to per-tool or whole-catalog compilation.
16+// ---------------------------------------------------------------------------
17+
18+import { describe, expect, it } from "@effect/vitest";
19+import { Effect } from "effect";
20+import { FetchHttpClient } from "effect/unstable/http";
21+import * as ts from "typescript";
22+
23+import {
24+ AuthTemplateSlug,
25+ ConnectionName,
26+ IntegrationSlug,
27+ createExecutor,
28+ generateToolProxySource,
29+} from "@executor-js/sdk";
30+import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
31+
32+import { openApiPlugin } from "./plugin";
33+
34+const TOOL_COUNT = 10_000;
35+
36+// Build a 10k-operation spec with realistic shape variety: per-operation
37+// parameter schemas, shared component refs, and enough distinct field names
38+// that deduplication cannot collapse the work.
39+const buildScaleSpec = (toolCount: number): string => {
40+ const paths: Record<string, unknown> = {};
41+ for (let index = 0; index < toolCount; index += 1) {
42+ paths[`/resources${index % 100}/r${index}`] = {
43+ get: {
44+ operationId: `res.op${index}`,
45+ summary: `Operation ${index}`,
46+ parameters: [
47+ { name: "id", in: "query", required: true, schema: { type: "string" } },
48+ { name: `filter${index % 250}`, in: "query", schema: { type: "string" } },
49+ { name: "limit", in: "query", schema: { type: "integer" } },
50+ ],
51+ responses: {
52+ "200": {
53+ description: "ok",
54+ content: {
55+ "application/json": {
56+ schema: {
57+ type: "object",
58+ properties: {
59+ item: { $ref: "#/components/schemas/Item" },
60+ page: { $ref: "#/components/schemas/Page" },
61+ total: { type: "integer" },
62+ },
63+ },
64+ },
65+ },
66+ },
67+ },
68+ },
69+ };
70+ }
71+ // @effect-diagnostics-next-line preferSchemaOverJson:off
72+ return JSON.stringify({
73+ openapi: "3.0.0",
74+ info: { title: "Scale", version: "1.0.0" },
75+ servers: [{ url: "https://scale.example.test" }],
76+ security: [{ apiKey: [] }],
77+ paths,
78+ components: {
79+ securitySchemes: {
80+ apiKey: { type: "apiKey", in: "header", name: "x-api-key" },
81+ },
82+ schemas: {
83+ Item: {
84+ type: "object",
85+ properties: {
86+ id: { type: "string" },
87+ name: { type: "string" },
88+ tags: { type: "array", items: { type: "string" } },
89+ },
90+ required: ["id"],
91+ },
92+ Page: {
93+ type: "object",
94+ properties: { cursor: { type: "string" }, hasMore: { type: "boolean" } },
95+ },
96+ },
97+ },
98+ });
99+};
100+
101+const typecheck = (source: string, extraSource: string): readonly string[] => {
102+ const fileName = "generated.ts";
103+ const fullSource = `${source}\n${extraSource}`;
104+ const options: ts.CompilerOptions = {
105+ strict: true,
106+ noEmit: true,
107+ skipLibCheck: true,
108+ target: ts.ScriptTarget.ES2022,
109+ module: ts.ModuleKind.ESNext,
110+ lib: ["lib.es2022.d.ts", "lib.dom.d.ts"],
111+ };
112+ const host = ts.createCompilerHost(options);
113+ const originalGetSourceFile = host.getSourceFile.bind(host);
114+ const originalReadFile = host.readFile.bind(host);
115+ const originalFileExists = host.fileExists.bind(host);
116+ host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) =>
117+ candidate === fileName
118+ ? ts.createSourceFile(candidate, fullSource, languageVersion, true)
119+ : originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile);
120+ host.readFile = (candidate) =>
121+ candidate === fileName ? fullSource : originalReadFile(candidate);
122+ host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate);
123+
124+ const program = ts.createProgram([fileName], options, host);
125+ return ts
126+ .getPreEmitDiagnostics(program)
127+ .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
128+};
129+
130+describe("typed proxy generation at 10k-tool scale", () => {
131+ it.effect(
132+ "exports, generates, and typechecks a 10,000-tool catalog",
133+ () =>
134+ Effect.scoped(
135+ Effect.gen(function* () {
136+ const executor = yield* createExecutor(
137+ makeTestConfig({
138+ plugins: [
139+ openApiPlugin({ httpClientLayer: FetchHttpClient.layer }),
140+ memoryCredentialsPlugin(),
141+ ] as const,
142+ }),
143+ );
144+
145+ const added = yield* executor.openapi.addSpec({
146+ spec: { kind: "blob", value: buildScaleSpec(TOOL_COUNT) },
147+ slug: "scale",
148+ });
149+ expect(added.toolCount).toBe(TOOL_COUNT);
150+
151+ yield* executor.connections.create({
152+ owner: "org",
153+ name: ConnectionName.make("main"),
154+ integration: IntegrationSlug.make("scale"),
155+ template: AuthTemplateSlug.make("apikey-0"),
156+ value: "token",
157+ });
158+
159+ const exportStart = performance.now();
160+ const exported = yield* executor.tools.export({
161+ integration: IntegrationSlug.make("scale"),
162+ });
163+ const exportMs = performance.now() - exportStart;
164+ const exportedCount = exported.connections.reduce(
165+ (sum, connection) => sum + connection.tools.length,
166+ 0,
167+ );
168+ expect(exportedCount).toBe(TOOL_COUNT);
169+
170+ const generateStart = performance.now();
171+ const generated = generateToolProxySource(exported);
172+ const generateMs = performance.now() - generateStart;
173+ expect(generated.toolCount).toBe(TOOL_COUNT);
174+
175+ // Regression tripwires, not benchmarks: whole-catalog single-pass
176+ // compilation measured 30s+ here and per-tool passes are far worse;
177+ // the chunked path runs in well under a second. 15s/30s absorb slow
178+ // CI machines while still failing on a complexity regression.
179+ expect(exportMs).toBeLessThan(15_000);
180+ expect(generateMs).toBeLessThan(30_000);
181+
182+ // Every tool surfaced in the generated interface exactly once. The
183+ // plugin derives paths from the URL (`/resources77/r7777` →
184+ // `resources77.resOp7777`), so count the leaf entries.
185+ const opMatches = generated.source.match(/resOp\d+: ExecutorToolFn</g) ?? [];
186+ expect(opMatches.length).toBe(TOOL_COUNT);
187+ // Shared component schemas stay named refs, not 10k inlined copies.
188+ expect(generated.source).toContain("export type Item =");
189+
190+ // The whole 10k-tool file typechecks under strict mode, and a
191+ // consumer gets real types out of an arbitrary tool in the middle.
192+ const diagnostics = typecheck(
193+ generated.source,
194+ `
195+ const client = createExecutorClient();
196+ async function main() {
197+ const outcome = await client.scale.org.main.resources77.resOp7777({ id: "x" });
198+ if (outcome.ok) {
199+ const total: number | undefined = outcome.data.total;
200+ const itemId: string | undefined = outcome.data.item?.id;
201+ void total;
202+ void itemId;
203+ }
204+ }
205+ void main;
206+ `,
207+ );
208+ expect(diagnostics).toEqual([]);
209+ }),
210+ ),
211+ { timeout: 180_000 },
212+ );
213+});