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

Showing the changes to packages/plugins/openapi (1 of 14 files). Show all

Unified · Split

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
18import { describe, expect, it } from "@effect/vitest";
19import { Effect } from "effect";
20import { FetchHttpClient } from "effect/unstable/http";
21import * as ts from "typescript";
22
23import {
24 AuthTemplateSlug,
25 ConnectionName,
26 IntegrationSlug,
27 createExecutor,
28 generateToolProxySource,
29} from "@executor-js/sdk";
30import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
31
32import { openApiPlugin } from "./plugin";
33
34const 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.
39const 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
101const 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
130describe("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});