History

Add local file-backed workspace tools

Rhys Sullivan committed 8 months ago

Commit ecb768b1 parent eb8d8106 files · +1404 −405Browse files

Unified · Split

6 files
  1. packages/control-plane/package.json +2 −1
  2. packages/control-plane/src/runtime/index.ts +37 −43
  3. packages/control-plane/src/runtime/local-errors.ts +60 −9
  4. packages/control-plane/src/runtime/local-tools.ts +654 −0
  5. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +372 −167
  6. packages/control-plane/src/runtime/workspace-execution-environment.ts +279 −185
packages/control-plane/package.json+2−1
@@ -37,7 +37,8 @@ · 36 unmodified lines
3737 "graphql": "^16.12.0",
3838 "jsonc-parser": "3.3.1",
3939 "postgres": "^3.4.7",
40− "prettier": "^3.8.1"
40+ "prettier": "^3.8.1",
41+ "typescript": "^5.9.3"
4142 },
4243 "devDependencies": {
4344 "@effect/vitest": "catalog:",
packages/control-plane/src/runtime/index.ts+37−43
@@ -4,9 +4,7 @@ import * as Exit from "effect/Exit"; · 3 unmodified lines
44 import * as Layer from "effect/Layer";
55 import * as Scope from "effect/Scope";
66
7−import {
8− type ControlPlaneApiRuntimeContext,
9−} from "#api";
7+import { type ControlPlaneApiRuntimeContext } from "#api";
108 import {
119 SqlControlPlanePersistenceLive,
1210 SqlControlPlanePersistenceService,
@@ -18,9 +16,7 @@ import { · 5 unmodified lines
1816 } from "#persistence";
1917 import type { LocalInstallation } from "#schema";
2018
21−import {
22− type ResolveExecutionEnvironment,
23−} from "./execution-state";
19+import { type ResolveExecutionEnvironment } from "./execution-state";
2420 import {
2521 createLiveExecutionManager,
2622 LiveExecutionManagerLive,
@@ -33,10 +29,7 @@ import { · 6 unmodified lines
3329 } from "./local-config";
3430 import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
3531 import { synchronizeLocalWorkspaceState } from "./local-workspace-sync";
36−import {
37− ControlPlaneStore,
38− ControlPlaneStoreLive,
39−} from "./store";
32+import { ControlPlaneStore, ControlPlaneStoreLive } from "./store";
4033 import {
4134 createRuntimeSourceAuthService,
4235 RuntimeSourceAuthServiceLive,
@@ -56,6 +49,7 @@ export * from "./live-execution"; · 13 unmodified lines
5649 export * from "./local-config";
5750 export * from "./local-installation";
5851 export * from "./local-source-artifacts";
52+export * from "./local-tools";
5953 export * from "./schema-type-signature";
6054 export * from "./source-auth-service";
6155 export * from "./secret-material-providers";
@@ -106,7 +100,7 @@ const runtimeContextTags = [ · 44 unmodified lines
106100
107101 const createRuntimeLayerFromContext = (
108102 context: Context.Context<
109− ControlPlaneStore
103+ | ControlPlaneStore
110104 | LiveExecutionManagerService
111105 | RuntimeLocalWorkspaceService
112106 | RuntimeSourceAuthServiceTag
@@ -116,7 +110,7 @@ const createRuntimeLayerFromContext = ( · 3 unmodified lines
116110 const runtimeContext = context.pipe(
117111 Context.pick(...runtimeContextTags),
118112 ) as Context.Context<
119− ControlPlaneStore
113+ | ControlPlaneStore
120114 | LiveExecutionManagerService
121115 | RuntimeLocalWorkspaceService
122116 | RuntimeSourceAuthServiceTag
@@ -132,15 +126,11 @@ export const createRuntimeControlPlaneLayer = ( · 9 unmodified lines
132126 const liveExecutionManagerLayer = LiveExecutionManagerLive;
133127 const sourceAuthLayer = RuntimeSourceAuthServiceLive({
134128 getLocalServerBaseUrl: options.getLocalServerBaseUrl,
135− }).pipe(
136− Layer.provide(liveExecutionManagerLayer),
137− );
129+ }).pipe(Layer.provide(liveExecutionManagerLayer));
138130 const executionResolverLayer = RuntimeExecutionResolverLive({
139131 executionResolver: options.executionResolver,
140132 resolveSecretMaterial: options.resolveSecretMaterial,
141− }).pipe(
142− Layer.provide(sourceAuthLayer),
143− );
133+ }).pipe(Layer.provide(sourceAuthLayer));
144134
145135 return Layer.mergeAll(
146136 ControlPlaneStoreLive,
@@ -157,8 +147,8 @@ export type ControlPlaneRuntime = { · 10 unmodified lines
157147 close: () => Promise<void>;
158148 };
159149
160−export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions
161− & RuntimeControlPlaneOptions;
150+export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions &
151+ RuntimeControlPlaneOptions;
162152
163153 export const createControlPlaneRuntime = (
164154 options: CreateControlPlaneRuntimeOptions,
@@ -168,14 +158,19 @@ export const createControlPlaneRuntime = ( · 3 unmodified lines
168158 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(
169159 Layer.provideMerge(SqlControlPlanePersistenceLive(options)),
170160 );
171− const baseContext = yield* Layer.buildWithScope(persistenceAndRowsLayer, scope).pipe(
161+ const baseContext = yield* Layer.buildWithScope(
162+ persistenceAndRowsLayer,
163+ scope,
164+ ).pipe(
172165 Effect.catchAll((error) =>
173− closeScope(scope).pipe(
174− Effect.zipRight(Effect.fail(error)),
175− )),
166+ closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
167+ ),
176168 );
177169
178− const persistence = Context.get(baseContext, SqlControlPlanePersistenceService);
170+ const persistence = Context.get(
171+ baseContext,
172+ SqlControlPlanePersistenceService,
173+ );
179174 const rows = Context.get(baseContext, SqlControlPlaneRowsService);
180175
181176 const localWorkspaceContext = yield* resolveLocalWorkspaceContext({
@@ -183,9 +178,8 @@ export const createControlPlaneRuntime = ( · 1 unmodified line
183178 }).pipe(
184179 Effect.mapError(toLocalRuntimeBootstrapError),
185180 Effect.catchAll((error) =>
186− closeScope(scope).pipe(
187− Effect.zipRight(Effect.fail(error)),
188− )),
181+ closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
182+ ),
189183 );
190184
191185 const localInstallation = yield* getOrProvisionLocalInstallation({
@@ -193,17 +187,17 @@ export const createControlPlaneRuntime = ( · 1 unmodified line
193187 }).pipe(
194188 Effect.mapError(toLocalRuntimeBootstrapError),
195189 Effect.catchAll((error) =>
196− closeScope(scope).pipe(
197− Effect.zipRight(Effect.fail(error)),
198− )),
190+ closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
191+ ),
199192 );
200193
201− const loadedLocalConfig = yield* loadLocalExecutorConfig(localWorkspaceContext).pipe(
194+ const loadedLocalConfig = yield* loadLocalExecutorConfig(
195+ localWorkspaceContext,
196+ ).pipe(
202197 Effect.mapError(toLocalRuntimeBootstrapError),
203198 Effect.catchAll((error) =>
204− closeScope(scope).pipe(
205− Effect.zipRight(Effect.fail(error)),
206− )),
199+ closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
200+ ),
207201 );
208202
209203 const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({
@@ -212,14 +206,13 @@ export const createControlPlaneRuntime = ( · 2 unmodified lines
212206 }).pipe(
213207 Effect.mapError(toLocalRuntimeBootstrapError),
214208 Effect.catchAll((error) =>
215− closeScope(scope).pipe(
216− Effect.zipRight(Effect.fail(error)),
217− )),
209+ closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
210+ ),
218211 );
219212
220213 const resolveSecretMaterial =
221− options.resolveSecretMaterial
222− ?? createDefaultSecretMaterialResolver({
214+ options.resolveSecretMaterial ??
215+ createDefaultSecretMaterialResolver({
223216 rows,
224217 localConfig: effectiveLocalConfig,
225218 workspaceRoot: localWorkspaceContext.workspaceRoot,
@@ -245,8 +238,8 @@ export const createControlPlaneRuntime = ( · 19 unmodified lines
245238 },
246239 });
247240 const executionResolver =
248− options.executionResolver
249− ?? createWorkspaceExecutionEnvironmentResolver({
241+ options.executionResolver ??
242+ createWorkspaceExecutionEnvironmentResolver({
250243 rows,
251244 sourceAuthService,
252245 resolveSecretMaterial,
@@ -275,6 +268,7 @@ export const createControlPlaneRuntime = ( · 22 unmodified lines
275268 persistence,
276269 localInstallation,
277270 runtimeLayer: concreteRuntimeLayer,
278− close: () => Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => {}),
271+ close: () =>
272+ Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => {}),
279273 };
280274 });
packages/control-plane/src/runtime/local-errors.ts+60−9
@@ -3,52 +3,103 @@ import * as Data from "effect/Data"; · 2 unmodified lines
33 export const unknownLocalErrorDetails = (cause: unknown): string =>
44 cause instanceof Error ? cause.message : String(cause);
55
6−export class LocalFileSystemError extends Data.TaggedError("LocalFileSystemError")<{
6+export class LocalFileSystemError extends Data.TaggedError(
7+ "LocalFileSystemError",
8+)<{
79 readonly message: string;
810 readonly action: string;
911 readonly path: string;
1012 readonly details: string;
1113 }> {}
1214
13−export class LocalExecutorConfigDecodeError extends Data.TaggedError("LocalExecutorConfigDecodeError")<{
15+export class LocalExecutorConfigDecodeError extends Data.TaggedError(
16+ "LocalExecutorConfigDecodeError",
17+)<{
1418 readonly message: string;
1519 readonly path: string;
1620 readonly details: string;
1721 }> {}
1822
19−export class LocalWorkspaceStateDecodeError extends Data.TaggedError("LocalWorkspaceStateDecodeError")<{
23+export class LocalWorkspaceStateDecodeError extends Data.TaggedError(
24+ "LocalWorkspaceStateDecodeError",
25+)<{
2026 readonly message: string;
2127 readonly path: string;
2228 readonly details: string;
2329 }> {}
2430
25−export class LocalSourceArtifactDecodeError extends Data.TaggedError("LocalSourceArtifactDecodeError")<{
31+export class LocalSourceArtifactDecodeError extends Data.TaggedError(
32+ "LocalSourceArtifactDecodeError",
33+)<{
2634 readonly message: string;
2735 readonly path: string;
2836 readonly details: string;
2937 }> {}
3038
31−export class RuntimeLocalWorkspaceUnavailableError extends Data.TaggedError("RuntimeLocalWorkspaceUnavailableError")<{
39+export class LocalToolTranspileError extends Data.TaggedError(
40+ "LocalToolTranspileError",
41+)<{
42+ readonly message: string;
43+ readonly path: string;
44+ readonly details: string;
45+}> {}
46+
47+export class LocalToolImportError extends Data.TaggedError(
48+ "LocalToolImportError",
49+)<{
50+ readonly message: string;
51+ readonly path: string;
52+ readonly details: string;
53+}> {}
54+
55+export class LocalToolDefinitionError extends Data.TaggedError(
56+ "LocalToolDefinitionError",
57+)<{
58+ readonly message: string;
59+ readonly path: string;
60+ readonly details: string;
61+}> {}
62+
63+export class LocalToolPathConflictError extends Data.TaggedError(
64+ "LocalToolPathConflictError",
65+)<{
66+ readonly message: string;
67+ readonly path: string;
68+ readonly otherPath: string;
69+ readonly toolPath: string;
70+}> {}
71+
72+export class RuntimeLocalWorkspaceUnavailableError extends Data.TaggedError(
73+ "RuntimeLocalWorkspaceUnavailableError",
74+)<{
3275 readonly message: string;
3376 }> {}
3477
35−export class RuntimeLocalWorkspaceMismatchError extends Data.TaggedError("RuntimeLocalWorkspaceMismatchError")<{
78+export class RuntimeLocalWorkspaceMismatchError extends Data.TaggedError(
79+ "RuntimeLocalWorkspaceMismatchError",
80+)<{
3681 readonly message: string;
3782 readonly requestedWorkspaceId: string;
3883 readonly activeWorkspaceId: string;
3984 }> {}
4085
41−export class LocalConfiguredSourceNotFoundError extends Data.TaggedError("LocalConfiguredSourceNotFoundError")<{
86+export class LocalConfiguredSourceNotFoundError extends Data.TaggedError(
87+ "LocalConfiguredSourceNotFoundError",
88+)<{
4289 readonly message: string;
4390 readonly sourceId: string;
4491 }> {}
4592
46−export class LocalSourceArtifactMissingError extends Data.TaggedError("LocalSourceArtifactMissingError")<{
93+export class LocalSourceArtifactMissingError extends Data.TaggedError(
94+ "LocalSourceArtifactMissingError",
95+)<{
4796 readonly message: string;
4897 readonly sourceId: string;
4998 }> {}
5099
51−export class LocalUnsupportedSourceKindError extends Data.TaggedError("LocalUnsupportedSourceKindError")<{
100+export class LocalUnsupportedSourceKindError extends Data.TaggedError(
101+ "LocalUnsupportedSourceKindError",
102+)<{
52103 readonly message: string;
53104 readonly kind: string;
54105 }> {}
packages/control-plane/src/runtime/local-tools.ts+654−0
@@ -0,0 +1,654 @@
1+import { createHash } from "node:crypto";
2+import { existsSync } from "node:fs";
3+import { dirname, extname, join, relative, resolve, sep } from "node:path";
4+import { fileURLToPath, pathToFileURL } from "node:url";
5+import { FileSystem } from "@effect/platform";
6+import { NodeFileSystem } from "@effect/platform-node";
7+import {
8+ createToolCatalogFromTools,
9+ makeToolInvokerFromTools,
10+ type ExecutableTool,
11+ type StandardSchema,
12+ type ToolCatalog,
13+ type ToolDefinition,
14+ type ToolInput,
15+ type ToolInvoker,
16+ type ToolMap,
17+ type ToolMetadata,
18+ type ToolPath,
19+} from "@executor/codemode-core";
20+import * as Effect from "effect/Effect";
21+import * as ts from "typescript";
22+
23+import type { ResolvedLocalWorkspaceContext } from "./local-config";
24+import {
25+ LocalFileSystemError,
26+ LocalToolDefinitionError,
27+ LocalToolImportError,
28+ LocalToolPathConflictError,
29+ LocalToolTranspileError,
30+ unknownLocalErrorDetails,
31+} from "./local-errors";
32+
33+const SUPPORTED_LOCAL_TOOL_EXTENSIONS = new Set([".ts", ".js", ".mjs"]);
34+const LOCAL_TOOLS_DIRECTORY = "tools";
35+const LOCAL_TOOLS_ARTIFACT_DIRECTORY = "local-tools";
36+const LOCAL_TOOLS_SOURCE_KEY_PREFIX = "local.tool";
37+const LOCAL_TOOL_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/;
38+
39+type Awaitable<T> = T | Promise<T>;
40+
41+type ParsedStandardSchema<TSchema extends StandardSchema<any, any>> =
42+ TSchema extends StandardSchema<any, infer Parsed> ? Parsed : never;
43+
44+export type LocalTool<
45+ TInputSchema extends StandardSchema<any, any> = StandardSchema<any, any>,
46+ TOutputSchema extends StandardSchema<any, any> | undefined =
47+ | StandardSchema<any, any>
48+ | undefined,
49+> = {
50+ description?: string;
51+ inputSchema: TInputSchema;
52+ outputSchema?: TOutputSchema;
53+ parameters?: TInputSchema;
54+ execute: (
55+ args: ParsedStandardSchema<TInputSchema>,
56+ ) => Awaitable<
57+ TOutputSchema extends StandardSchema<any, any>
58+ ? ParsedStandardSchema<TOutputSchema>
59+ : unknown
60+ >;
61+};
62+
63+export type LocalToolWithMetadata<
64+ TInputSchema extends StandardSchema<any, any> = StandardSchema<any, any>,
65+ TOutputSchema extends StandardSchema<any, any> | undefined =
66+ | StandardSchema<any, any>
67+ | undefined,
68+> = {
69+ tool: LocalTool<TInputSchema, TOutputSchema>;
70+ metadata?: Omit<ToolMetadata, "sourceKey">;
71+};
72+
73+export const defineLocalTool = <
74+ TInputSchema extends StandardSchema<any, any>,
75+ TOutputSchema extends StandardSchema<any, any> | undefined = undefined,
76+>(
77+ input:
78+ | LocalTool<TInputSchema, TOutputSchema>
79+ | LocalToolWithMetadata<TInputSchema, TOutputSchema>,
80+): typeof input => input;
81+
82+export type LocalToolRuntime = {
83+ tools: ToolMap;
84+ catalog: ToolCatalog;
85+ toolInvoker: ToolInvoker;
86+ toolPaths: Set<string>;
87+};
88+
89+const emptyLocalToolRuntime = (): LocalToolRuntime => {
90+ const tools: ToolMap = {};
91+ return {
92+ tools,
93+ catalog: createToolCatalogFromTools({ tools }),
94+ toolInvoker: makeToolInvokerFromTools({ tools }),
95+ toolPaths: new Set(),
96+ };
97+};
98+
99+const provideNodeFileSystem = <A, E, R>(
100+ effect: Effect.Effect<A, E, R | FileSystem.FileSystem>,
101+): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> =>
102+ effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect<
103+ A,
104+ E,
105+ Exclude<R, FileSystem.FileSystem>
106+ >;
107+
108+const mapFileSystemError = (path: string, action: string) => (cause: unknown) =>
109+ new LocalFileSystemError({
110+ message: `Failed to ${action} ${path}: ${unknownLocalErrorDetails(cause)}`,
111+ action,
112+ path,
113+ details: unknownLocalErrorDetails(cause),
114+ });
115+
116+const localToolsDirectory = (context: ResolvedLocalWorkspaceContext): string =>
117+ join(context.configDirectory, LOCAL_TOOLS_DIRECTORY);
118+
119+const localToolsArtifactDirectory = (
120+ context: ResolvedLocalWorkspaceContext,
121+): string => join(context.artifactsDirectory, LOCAL_TOOLS_ARTIFACT_DIRECTORY);
122+
123+const isSupportedLocalToolFile = (path: string): boolean =>
124+ SUPPORTED_LOCAL_TOOL_EXTENSIONS.has(extname(path));
125+
126+const isIgnoredToolSegment = (segment: string): boolean =>
127+ segment.startsWith(".") || segment.startsWith("_");
128+
129+const toArtifactRelativePath = (relativePath: string): string => {
130+ const extension = extname(relativePath);
131+ return `${relativePath.slice(0, -extension.length)}.js`;
132+};
133+
134+const toToolPath = (
135+ relativePath: string,
136+): Effect.Effect<ToolPath, LocalToolDefinitionError> =>
137+ Effect.gen(function* () {
138+ const withoutExtension = relativePath.slice(
139+ 0,
140+ -extname(relativePath).length,
141+ );
142+ const rawSegments = withoutExtension.split(sep).filter(Boolean);
143+ const segments =
144+ rawSegments.at(-1) === "index" ? rawSegments.slice(0, -1) : rawSegments;
145+
146+ if (segments.length === 0) {
147+ return yield* Effect.fail(
148+ new LocalToolDefinitionError({
149+ message: `Invalid local tool path ${relativePath}: root index files are not supported`,
150+ path: relativePath,
151+ details:
152+ "Root index files are not supported. Use a named file such as hello.ts or a nested index.ts.",
153+ }),
154+ );
155+ }
156+
157+ for (const segment of segments) {
158+ if (!LOCAL_TOOL_SEGMENT_PATTERN.test(segment)) {
159+ return yield* Effect.fail(
160+ new LocalToolDefinitionError({
161+ message: `Invalid local tool path ${relativePath}: segment ${segment} contains unsupported characters`,
162+ path: relativePath,
163+ details: `Tool path segments may only contain letters, numbers, underscores, and hyphens. Invalid segment: ${segment}`,
164+ }),
165+ );
166+ }
167+ }
168+
169+ return segments.join(".") as ToolPath;
170+ });
171+
172+const readDirectoryEntries = (path: string) =>
173+ Effect.gen(function* () {
174+ const fs = yield* FileSystem.FileSystem;
175+ const entries = yield* fs
176+ .readDirectory(path)
177+ .pipe(
178+ Effect.mapError(mapFileSystemError(path, "read local tools directory")),
179+ );
180+ return entries.sort((left, right) => left.localeCompare(right));
181+ });
182+
183+const collectLocalToolSourceFiles = (
184+ root: string,
185+): Effect.Effect<string[], LocalFileSystemError, FileSystem.FileSystem> =>
186+ Effect.gen(function* () {
187+ const fs = yield* FileSystem.FileSystem;
188+ const exists = yield* fs
189+ .exists(root)
190+ .pipe(
191+ Effect.mapError(
192+ mapFileSystemError(root, "check local tools directory"),
193+ ),
194+ );
195+ if (!exists) {
196+ return [];
197+ }
198+
199+ const walk = (
200+ directory: string,
201+ ): Effect.Effect<string[], LocalFileSystemError, FileSystem.FileSystem> =>
202+ Effect.gen(function* () {
203+ const entries = yield* readDirectoryEntries(directory);
204+ const files: string[] = [];
205+
206+ for (const entry of entries) {
207+ const fullPath = join(directory, entry);
208+ const info = yield* fs
209+ .stat(fullPath)
210+ .pipe(
211+ Effect.mapError(
212+ mapFileSystemError(fullPath, "stat local tool path"),
213+ ),
214+ );
215+
216+ if (info.type === "Directory") {
217+ files.push(...(yield* walk(fullPath)));
218+ continue;
219+ }
220+
221+ if (info.type === "File" && isSupportedLocalToolFile(fullPath)) {
222+ files.push(fullPath);
223+ }
224+ }
225+
226+ return files;
227+ });
228+
229+ return yield* walk(root);
230+ });
231+
232+const toolCandidateFiles = (root: string, files: readonly string[]): string[] =>
233+ files.filter((path) =>
234+ relative(root, path)
235+ .split(sep)
236+ .every((segment) => !isIgnoredToolSegment(segment)),
237+ );
238+
239+const resolveCompilerDiagnostics = (
240+ sourcePath: string,
241+ diagnostics: readonly ts.Diagnostic[] | undefined,
242+): LocalToolTranspileError | null => {
243+ const issues = (diagnostics ?? [])
244+ .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)
245+ .map((diagnostic) => {
246+ const message = ts.flattenDiagnosticMessageText(
247+ diagnostic.messageText,
248+ "\n",
249+ );
250+ if (!diagnostic.file || diagnostic.start === undefined) {
251+ return message;
252+ }
253+ const position = diagnostic.file.getLineAndCharacterOfPosition(
254+ diagnostic.start,
255+ );
256+ return `${position.line + 1}:${position.character + 1} ${message}`;
257+ });
258+
259+ if (issues.length === 0) {
260+ return null;
261+ }
262+
263+ return new LocalToolTranspileError({
264+ message: `Failed to transpile local tool ${sourcePath}`,
265+ path: sourcePath,
266+ details: issues.join("\n"),
267+ });
268+};
269+
270+const normalizeRelativeImportSpecifier = (specifier: string): string => {
271+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) {
272+ return specifier;
273+ }
274+
275+ const match = specifier.match(/^(.*?)(\?.*|#.*)?$/);
276+ const pathname = match?.[1] ?? specifier;
277+ const suffix = match?.[2] ?? "";
278+ const extension = extname(pathname);
279+
280+ if (extension === ".js") {
281+ return specifier;
282+ }
283+
284+ if ([".ts", ".mjs"].includes(extension)) {
285+ return `${pathname.slice(0, -extension.length)}.js${suffix}`;
286+ }
287+
288+ if (extension.length > 0) {
289+ return specifier;
290+ }
291+
292+ return `${pathname}.js${suffix}`;
293+};
294+
295+const rewriteRelativeImportSpecifiers = (code: string): string =>
296+ code
297+ .replace(
298+ /(from\s+["'])(\.{1,2}\/[^"']+)(["'])/g,
299+ (_match, prefix: string, specifier: string, suffix: string) =>
300+ `${prefix}${normalizeRelativeImportSpecifier(specifier)}${suffix}`,
301+ )
302+ .replace(
303+ /(import\s+["'])(\.{1,2}\/[^"']+)(["'])/g,
304+ (_match, prefix: string, specifier: string, suffix: string) =>
305+ `${prefix}${normalizeRelativeImportSpecifier(specifier)}${suffix}`,
306+ )
307+ .replace(
308+ /(import\(\s*["'])(\.{1,2}\/[^"']+)(["']\s*\))/g,
309+ (_match, prefix: string, specifier: string, suffix: string) =>
310+ `${prefix}${normalizeRelativeImportSpecifier(specifier)}${suffix}`,
311+ );
312+
313+const transpileSourceFile = (input: {
314+ sourcePath: string;
315+ content: string;
316+}): Effect.Effect<string, LocalToolTranspileError> =>
317+ Effect.gen(function* () {
318+ if (extname(input.sourcePath) !== ".ts") {
319+ return rewriteRelativeImportSpecifiers(input.content);
320+ }
321+
322+ const transpiled = ts.transpileModule(input.content, {
323+ fileName: input.sourcePath,
324+ compilerOptions: {
325+ module: ts.ModuleKind.ESNext,
326+ moduleResolution: ts.ModuleResolutionKind.Bundler,
327+ target: ts.ScriptTarget.ES2022,
328+ sourceMap: false,
329+ inlineSourceMap: false,
330+ inlineSources: false,
331+ },
332+ reportDiagnostics: true,
333+ });
334+
335+ const diagnosticError = resolveCompilerDiagnostics(
336+ input.sourcePath,
337+ transpiled.diagnostics,
338+ );
339+ if (diagnosticError) {
340+ return yield* Effect.fail(diagnosticError);
341+ }
342+
343+ return rewriteRelativeImportSpecifiers(transpiled.outputText);
344+ });
345+
346+const resolveExecutorNodeModulesDirectory = (): string => {
347+ let current = dirname(fileURLToPath(import.meta.url));
348+
349+ while (true) {
350+ const candidate = join(current, "node_modules");
351+ if (existsSync(candidate)) {
352+ return candidate;
353+ }
354+
355+ const parent = dirname(current);
356+ if (parent === current) {
357+ throw new Error(
358+ "Unable to locate Executor node_modules directory for local tools",
359+ );
360+ }
361+ current = parent;
362+ }
363+};
364+
365+const EXECUTOR_NODE_MODULES_DIRECTORY = resolveExecutorNodeModulesDirectory();
366+
367+const ensureArtifactNodeModulesLink = (artifactRoot: string) =>
368+ Effect.gen(function* () {
369+ const fs = yield* FileSystem.FileSystem;
370+ const linkPath = join(artifactRoot, "node_modules");
371+ const exists = yield* fs
372+ .exists(linkPath)
373+ .pipe(
374+ Effect.mapError(
375+ mapFileSystemError(linkPath, "check local tool node_modules link"),
376+ ),
377+ );
378+ if (exists) {
379+ return;
380+ }
381+
382+ yield* fs
383+ .symlink(EXECUTOR_NODE_MODULES_DIRECTORY, linkPath)
384+ .pipe(
385+ Effect.mapError(
386+ mapFileSystemError(linkPath, "create local tool node_modules link"),
387+ ),
388+ );
389+ });
390+
391+const buildLocalToolArtifacts = (input: {
392+ context: ResolvedLocalWorkspaceContext;
393+ sourceDirectory: string;
394+ sourceFiles: readonly string[];
395+}): Effect.Effect<
396+ Map<string, string>,
397+ LocalFileSystemError | LocalToolTranspileError,
398+ FileSystem.FileSystem
399+> =>
400+ Effect.gen(function* () {
401+ const fs = yield* FileSystem.FileSystem;
402+ const sources = yield* Effect.forEach(input.sourceFiles, (sourcePath) =>
403+ Effect.gen(function* () {
404+ const content = yield* fs
405+ .readFileString(sourcePath, "utf8")
406+ .pipe(
407+ Effect.mapError(
408+ mapFileSystemError(sourcePath, "read local tool source"),
409+ ),
410+ );
411+ const relativePath = relative(input.sourceDirectory, sourcePath);
412+ const contentHash = createHash("sha256")
413+ .update(relativePath)
414+ .update("\0")
415+ .update(content)
416+ .digest("hex")
417+ .slice(0, 12);
418+ return {
419+ sourcePath,
420+ relativePath,
421+ content,
422+ contentHash,
423+ };
424+ }),
425+ );
426+
427+ const buildHash = createHash("sha256")
428+ .update(
429+ JSON.stringify(
430+ sources.map((source) => [source.relativePath, source.contentHash]),
431+ ),
432+ )
433+ .digest("hex")
434+ .slice(0, 16);
435+ const artifactRoot = join(
436+ localToolsArtifactDirectory(input.context),
437+ buildHash,
438+ );
439+
440+ yield* fs
441+ .makeDirectory(artifactRoot, { recursive: true })
442+ .pipe(
443+ Effect.mapError(
444+ mapFileSystemError(
445+ artifactRoot,
446+ "create local tool artifact directory",
447+ ),
448+ ),
449+ );
450+ yield* ensureArtifactNodeModulesLink(artifactRoot);
451+
452+ const output = new Map<string, string>();
453+
454+ for (const source of sources) {
455+ const artifactRelativePath = toArtifactRelativePath(source.relativePath);
456+ const artifactPath = join(artifactRoot, artifactRelativePath);
457+ const artifactDirectory = dirname(artifactPath);
458+ const transpiled = yield* transpileSourceFile({
459+ sourcePath: source.sourcePath,
460+ content: source.content,
461+ });
462+
463+ yield* fs
464+ .makeDirectory(artifactDirectory, { recursive: true })
465+ .pipe(
466+ Effect.mapError(
467+ mapFileSystemError(
468+ artifactDirectory,
469+ "create local tool artifact parent directory",
470+ ),
471+ ),
472+ );
473+ yield* fs
474+ .writeFileString(artifactPath, transpiled)
475+ .pipe(
476+ Effect.mapError(
477+ mapFileSystemError(artifactPath, "write local tool artifact"),
478+ ),
479+ );
480+
481+ output.set(source.sourcePath, artifactPath);
482+ }
483+
484+ return output;
485+ });
486+
487+const isExecutableTool = (value: unknown): value is ExecutableTool =>
488+ typeof value === "object" &&
489+ value !== null &&
490+ "inputSchema" in value &&
491+ typeof (value as { execute?: unknown }).execute === "function";
492+
493+const isToolDefinition = (value: unknown): value is ToolDefinition =>
494+ typeof value === "object" &&
495+ value !== null &&
496+ "tool" in value &&
497+ isExecutableTool((value as { tool?: unknown }).tool);
498+
499+const normalizeToolExport = (input: {
500+ toolPath: ToolPath;
501+ sourcePath: string;
502+ exported: unknown;
503+}): Effect.Effect<ToolInput, LocalToolDefinitionError> => {
504+ const sourceKey = `${LOCAL_TOOLS_SOURCE_KEY_PREFIX}.${input.toolPath}`;
505+
506+ if (isToolDefinition(input.exported)) {
507+ const definition = input.exported;
508+ return Effect.succeed({
509+ ...definition,
510+ metadata: {
511+ ...(definition.metadata ?? {}),
512+ sourceKey,
513+ },
514+ });
515+ }
516+
517+ if (isExecutableTool(input.exported)) {
518+ const tool = input.exported;
519+ return Effect.succeed({
520+ tool,
521+ metadata: {
522+ sourceKey,
523+ },
524+ });
525+ }
526+
527+ return Effect.fail(
528+ new LocalToolDefinitionError({
529+ message: `Invalid local tool export in ${input.sourcePath}`,
530+ path: input.sourcePath,
531+ details:
532+ "Local tool files must export a default value or named `tool` export containing either an executable tool or a `{ tool, metadata? }` definition.",
533+ }),
534+ );
535+};
536+
537+const importLocalToolModule = (input: {
538+ sourcePath: string;
539+ artifactPath: string;
540+ toolPath: ToolPath;
541+}): Effect.Effect<ToolInput, LocalToolImportError | LocalToolDefinitionError> =>
542+ Effect.tryPromise({
543+ try: () => import(pathToFileURL(resolve(input.artifactPath)).href),
544+ catch: (cause) =>
545+ new LocalToolImportError({
546+ message: `Failed to import local tool ${input.sourcePath}`,
547+ path: input.sourcePath,
548+ details: unknownLocalErrorDetails(cause),
549+ }),
550+ }).pipe(
551+ Effect.flatMap((mod) => {
552+ const hasDefault = mod.default !== undefined;
553+ const hasNamedTool = mod.tool !== undefined;
554+
555+ if (hasDefault && hasNamedTool) {
556+ return Effect.fail(
557+ new LocalToolDefinitionError({
558+ message: `Invalid local tool export in ${input.sourcePath}`,
559+ path: input.sourcePath,
560+ details:
561+ "Export either a default tool or a named `tool` export, but not both.",
562+ }),
563+ );
564+ }
565+
566+ if (!hasDefault && !hasNamedTool) {
567+ return Effect.fail(
568+ new LocalToolDefinitionError({
569+ message: `Missing local tool export in ${input.sourcePath}`,
570+ path: input.sourcePath,
571+ details: "Expected a default export or named `tool` export.",
572+ }),
573+ );
574+ }
575+
576+ return normalizeToolExport({
577+ toolPath: input.toolPath,
578+ sourcePath: input.sourcePath,
579+ exported: hasDefault ? mod.default : mod.tool,
580+ });
581+ }),
582+ );
583+
584+export const loadLocalToolRuntime = (
585+ context: ResolvedLocalWorkspaceContext,
586+): Effect.Effect<
587+ LocalToolRuntime,
588+ | LocalFileSystemError
589+ | LocalToolDefinitionError
590+ | LocalToolImportError
591+ | LocalToolPathConflictError
592+ | LocalToolTranspileError
593+> =>
594+ provideNodeFileSystem(
595+ Effect.gen(function* () {
596+ const sourceDirectory = localToolsDirectory(context);
597+ const sourceFiles = yield* collectLocalToolSourceFiles(sourceDirectory);
598+
599+ if (sourceFiles.length === 0) {
600+ return emptyLocalToolRuntime();
601+ }
602+
603+ const artifactPaths = yield* buildLocalToolArtifacts({
604+ context,
605+ sourceDirectory,
606+ sourceFiles,
607+ });
608+ const candidateFiles = toolCandidateFiles(sourceDirectory, sourceFiles);
609+ const tools: ToolMap = {};
610+ const seenToolPaths = new Map<string, string>();
611+
612+ for (const sourcePath of candidateFiles) {
613+ const relativePath = relative(sourceDirectory, sourcePath);
614+ const toolPath = yield* toToolPath(relativePath);
615+ const existing = seenToolPaths.get(toolPath);
616+ if (existing) {
617+ return yield* Effect.fail(
618+ new LocalToolPathConflictError({
619+ message: `Local tool path conflict for ${toolPath}`,
620+ path: sourcePath,
621+ otherPath: existing,
622+ toolPath,
623+ }),
624+ );
625+ }
626+
627+ const artifactPath = artifactPaths.get(sourcePath);
628+ if (!artifactPath) {
629+ return yield* Effect.fail(
630+ new LocalToolImportError({
631+ message: `Missing compiled artifact for local tool ${sourcePath}`,
632+ path: sourcePath,
633+ details:
634+ "Expected a compiled local tool artifact, but none was produced.",
635+ }),
636+ );
637+ }
638+
639+ tools[toolPath] = yield* importLocalToolModule({
640+ sourcePath,
641+ artifactPath,
642+ toolPath,
643+ });
644+ seenToolPaths.set(toolPath, sourcePath);
645+ }
646+
647+ return {
648+ tools,
649+ catalog: createToolCatalogFromTools({ tools }),
650+ toolInvoker: makeToolInvokerFromTools({ tools }),
651+ toolPaths: new Set(Object.keys(tools)),
652+ } satisfies LocalToolRuntime;
653+ }),
654+ );
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+372−167
@@ -1,10 +1,9 @@
11 import { randomUUID } from "node:crypto";
2−import { mkdtempSync } from "node:fs";
32 import { createServer } from "node:http";
4−import { tmpdir } from "node:os";
5−import { join } from "node:path";
3+import { dirname, join } from "node:path";
64
75 import {
6+ FileSystem,
87 HttpApi,
98 HttpApiBuilder,
109 HttpApiEndpoint,
@@ -12,6 +11,7 @@ import { · 1 unmodified line
1211 HttpApiSchema,
1312 HttpServer,
1413 } from "@effect/platform";
14+import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
1515 import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
1616 import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
1717 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -80,9 +80,7 @@ import { · 62 unmodified lines
8080 loadLocalWorkspaceState,
8181 writeLocalWorkspaceState,
8282 } from "./local-workspace-state";
83−import {
84− materializationFromMcpManifestEntries,
85−} from "./source-adapters/mcp";
83+import { materializationFromMcpManifestEntries } from "./source-adapters/mcp";
8684 import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
8785
8886 type CountedMcpServer = {
@@ -116,6 +114,15 @@ const normalizeSearchText = ( · 27 unmodified lines
116114 .join(" ")
117115 .toLowerCase();
118116
117+const provideNodeFileSystem = <A, E, R>(
118+ effect: Effect.Effect<A, E, R | FileSystem.FileSystem>,
119+): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> =>
120+ effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect<
121+ A,
122+ E,
123+ Exclude<R, FileSystem.FileSystem>
124+ >;
125+
119126 const GraphqlUserType = new GraphQLObjectType({
120127 name: "User",
121128 fields: {
@@ -941,98 +948,98 @@ const persistConnectedGithubOpenApiSource = (input: { · 819 unmodified lines
941948 context: input.runtimeLocalWorkspace.context,
942949 sourceId: source.id,
943950 artifact: buildLocalSourceArtifact({
944− source: {
945− ...source,
946− sourceHash: manifest.sourceHash,
947− },
948− materialization: {
949− manifestJson: JSON.stringify(manifest),
950− manifestHash: manifest.sourceHash,
951− sourceHash: manifest.sourceHash,
952− documents: [
953− {
954− id: `src_recipe_doc_${randomUUID()}`,
955− recipeRevisionId: "src_recipe_rev_materialization" as never,
956− documentKind: "openapi",
957− documentKey:
958− typeof source.binding.specUrl === "string"
959− ? source.binding.specUrl
960− : source.endpoint,
961− contentText: openApiDocumentText,
962− contentHash: manifest.sourceHash,
963− fetchedAt: now,
964− createdAt: now,
965− updatedAt: now,
966− },
967− ],
968− schemaBundles:
969− refEntries.length > 0
970− ? [
971− {
972− id: SourceRecipeSchemaBundleIdSchema.make(
973− `src_recipe_bundle_${randomUUID()}`,
974− ),
975− recipeRevisionId: "src_recipe_rev_materialization" as never,
976− bundleKind: "json_schema_ref_map",
977− refsJson: JSON.stringify(
978− Object.fromEntries(
979− refEntries.map(([ref, rawValue]) => {
980− try {
981− return [ref, JSON.parse(rawValue) as unknown];
982− } catch {
983− return [ref, rawValue];
984− }
985− }),
986− ),
987− ),
988− contentHash: manifest.sourceHash,
989− createdAt: now,
990− updatedAt: now,
991− },
992− ]
993− : [],
994− operations: definitions.map((definition) => {
995− const presentation = buildOpenApiToolPresentation({
996− definition,
997− });
998− const method = definition.method.toUpperCase();
999−
1000− return {
1001− id: `src_recipe_op_${randomUUID()}`,
1002− recipeRevisionId: "src_recipe_rev_materialization" as never,
1003− operationKey: definition.toolId,
1004− transportKind: "http",
1005− toolId: definition.toolId,
1006− title: definition.name,
1007− description: definition.description,
1008− operationKind:
1009− method === "GET" || method === "HEAD"
1010− ? "read"
1011− : method === "DELETE"
1012− ? "delete"
1013− : "write",
1014− searchText: normalizeSearchText(
1015− definition.toolId,
1016− definition.name,
1017− definition.description,
1018− definition.rawToolId,
1019− definition.operationId ?? undefined,
1020− definition.method,
1021− definition.path,
1022− definition.group,
1023− definition.leaf,
1024− definition.tags.join(" "),
1025− ),
1026− inputSchemaJson: presentation.inputSchemaJson ?? null,
1027− outputSchemaJson: presentation.outputSchemaJson ?? null,
1028− providerKind: "openapi",
1029− providerDataJson: presentation.providerDataJson,
1030− createdAt: now,
1031− updatedAt: now,
1032− };
1033− }),
951+ source: {
952+ ...source,
953+ sourceHash: manifest.sourceHash,
954+ },
955+ materialization: {
956+ manifestJson: JSON.stringify(manifest),
957+ manifestHash: manifest.sourceHash,
958+ sourceHash: manifest.sourceHash,
959+ documents: [
960+ {
961+ id: `src_recipe_doc_${randomUUID()}`,
962+ recipeRevisionId: "src_recipe_rev_materialization" as never,
963+ documentKind: "openapi",
964+ documentKey:
965+ typeof source.binding.specUrl === "string"
966+ ? source.binding.specUrl
967+ : source.endpoint,
968+ contentText: openApiDocumentText,
969+ contentHash: manifest.sourceHash,
970+ fetchedAt: now,
971+ createdAt: now,
972+ updatedAt: now,
1034973 },
974+ ],
975+ schemaBundles:
976+ refEntries.length > 0
977+ ? [
978+ {
979+ id: SourceRecipeSchemaBundleIdSchema.make(
980+ `src_recipe_bundle_${randomUUID()}`,
981+ ),
982+ recipeRevisionId: "src_recipe_rev_materialization" as never,
983+ bundleKind: "json_schema_ref_map",
984+ refsJson: JSON.stringify(
985+ Object.fromEntries(
986+ refEntries.map(([ref, rawValue]) => {
987+ try {
988+ return [ref, JSON.parse(rawValue) as unknown];
989+ } catch {
990+ return [ref, rawValue];
991+ }
992+ }),
993+ ),
994+ ),
995+ contentHash: manifest.sourceHash,
996+ createdAt: now,
997+ updatedAt: now,
998+ },
999+ ]
1000+ : [],
1001+ operations: definitions.map((definition) => {
1002+ const presentation = buildOpenApiToolPresentation({
1003+ definition,
1004+ });
1005+ const method = definition.method.toUpperCase();
1006+
1007+ return {
1008+ id: `src_recipe_op_${randomUUID()}`,
1009+ recipeRevisionId: "src_recipe_rev_materialization" as never,
1010+ operationKey: definition.toolId,
1011+ transportKind: "http",
1012+ toolId: definition.toolId,
1013+ title: definition.name,
1014+ description: definition.description,
1015+ operationKind:
1016+ method === "GET" || method === "HEAD"
1017+ ? "read"
1018+ : method === "DELETE"
1019+ ? "delete"
1020+ : "write",
1021+ searchText: normalizeSearchText(
1022+ definition.toolId,
1023+ definition.name,
1024+ definition.description,
1025+ definition.rawToolId,
1026+ definition.operationId ?? undefined,
1027+ definition.method,
1028+ definition.path,
1029+ definition.group,
1030+ definition.leaf,
1031+ definition.tags.join(" "),
1032+ ),
1033+ inputSchemaJson: presentation.inputSchemaJson ?? null,
1034+ outputSchemaJson: presentation.outputSchemaJson ?? null,
1035+ providerKind: "openapi",
1036+ providerDataJson: presentation.providerDataJson,
1037+ createdAt: now,
1038+ updatedAt: now,
1039+ };
10351040 }),
1041+ },
1042+ }),
10361043 });
10371044 yield* writeLocalWorkspaceState({
10381045 context: input.runtimeLocalWorkspace.context,
@@ -1054,62 +1061,104 @@ const persistConnectedGithubOpenApiSource = (input: { · 15 unmodified lines
10541061 return;
10551062 }) as Effect.Effect<void, Error, never>;
10561063
1057−const makeRuntimeLocalWorkspaceState = (workspaceId: WorkspaceId): Effect.Effect<RuntimeLocalWorkspaceState, Error, never> =>
1058− Effect.gen(function* () {
1059− const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-workspace-env-"));
1060− const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
1061− const loadedConfig = yield* loadLocalExecutorConfig(context);
1062− return {
1063− context,
1064− installation: {
1065− workspaceId,
1066− accountId: AccountIdSchema.make("acc_local_test"),
1067− },
1068− loadedConfig,
1069− } satisfies RuntimeLocalWorkspaceState;
1070− });
1071−
1072−const makeResolver = (
1073− persistence: SqlControlPlanePersistence,
1074− runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1075−) =>
1076− ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>
1064+const makeRuntimeLocalWorkspaceState = (
1065+ workspaceId: WorkspaceId,
1066+): Effect.Effect<RuntimeLocalWorkspaceState, Error, Scope.Scope> =>
1067+ provideNodeFileSystem(
1068+ Effect.gen(function* () {
1069+ const fs = yield* FileSystem.FileSystem;
1070+ const workspaceRoot = yield* fs.makeTempDirectoryScoped({
1071+ prefix: "executor-workspace-env-",
1072+ });
1073+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
1074+ const loadedConfig = yield* loadLocalExecutorConfig(context);
1075+ return {
1076+ context,
1077+ installation: {
1078+ workspaceId,
1079+ accountId: AccountIdSchema.make("acc_local_test"),
1080+ },
1081+ loadedConfig,
1082+ } satisfies RuntimeLocalWorkspaceState;
1083+ }),
1084+ );
1085+
1086+const writeLocalToolSource = (input: {
1087+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState;
1088+ relativePath: string;
1089+ content: string;
1090+}): Effect.Effect<void, Error, never> =>
1091+ provideNodeFileSystem(
1092+ Effect.gen(function* () {
1093+ const fs = yield* FileSystem.FileSystem;
1094+ const toolPath = join(
1095+ input.runtimeLocalWorkspace.context.configDirectory,
1096+ "tools",
1097+ input.relativePath,
1098+ );
1099+ yield* fs.makeDirectory(dirname(toolPath), { recursive: true });
1100+ yield* fs.writeFileString(toolPath, input.content);
1101+ }),
1102+ );
1103+
1104+const makeResolver =
1105+ (
1106+ persistence: SqlControlPlanePersistence,
1107+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1108+ ) =>
1109+ (
1110+ input: Parameters<
1111+ ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
1112+ >[0],
1113+ ) =>
10771114 createWorkspaceExecutionEnvironmentResolver({
1078− rows: persistence.rows,
1079− sourceAuthService: {
1080− getLocalServerBaseUrl: () => null,
1081− storeSecretMaterial: () =>
1082− Effect.fail(new Error("not implemented in test")),
1083− getSourceById: () => Effect.fail(new Error("not implemented in test")),
1084− addExecutorSource: () =>
1085− Effect.fail(new Error("not implemented in test")),
1086− connectMcpSource: () => Effect.fail(new Error("not implemented in test")),
1087− startSourceOAuthSession: () =>
1088− Effect.fail(new Error("not implemented in test")),
1089− completeSourceOAuthSession: () =>
1090− Effect.fail(new Error("not implemented in test")),
1091− completeSourceCredentialSetup: () =>
1092− Effect.fail(new Error("not implemented in test")),
1093− } as RuntimeSourceAuthService,
1094− })(input).pipe(
1095− Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
1096− ));
1097−
1098−const makeResolverWithLiveSourceAuth = (
1099− persistence: SqlControlPlanePersistence,
1100− runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1101−) =>
1102− ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>
1115+ rows: persistence.rows,
1116+ sourceAuthService: {
1117+ getLocalServerBaseUrl: () => null,
1118+ storeSecretMaterial: () =>
1119+ Effect.fail(new Error("not implemented in test")),
1120+ getSourceById: () => Effect.fail(new Error("not implemented in test")),
1121+ addExecutorSource: () =>
1122+ Effect.fail(new Error("not implemented in test")),
1123+ connectMcpSource: () =>
1124+ Effect.fail(new Error("not implemented in test")),
1125+ startSourceOAuthSession: () =>
1126+ Effect.fail(new Error("not implemented in test")),
1127+ completeSourceOAuthSession: () =>
1128+ Effect.fail(new Error("not implemented in test")),
1129+ completeSourceCredentialSetup: () =>
1130+ Effect.fail(new Error("not implemented in test")),
1131+ } as RuntimeSourceAuthService,
1132+ })(input).pipe(
1133+ Effect.provideService(
1134+ RuntimeLocalWorkspaceService,
1135+ runtimeLocalWorkspace,
1136+ ),
1137+ );
1138+
1139+const makeResolverWithLiveSourceAuth =
1140+ (
1141+ persistence: SqlControlPlanePersistence,
1142+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1143+ ) =>
1144+ (
1145+ input: Parameters<
1146+ ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
1147+ >[0],
1148+ ) =>
11031149 createWorkspaceExecutionEnvironmentResolver({
1104− rows: persistence.rows,
1105− sourceAuthService: createRuntimeSourceAuthService({
11061150 rows: persistence.rows,
1107− liveExecutionManager: createLiveExecutionManager(),
1108− getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
1109− }),
1110− })(input).pipe(
1111− Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
1112− ));
1151+ sourceAuthService: createRuntimeSourceAuthService({
1152+ rows: persistence.rows,
1153+ liveExecutionManager: createLiveExecutionManager(),
1154+ getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
1155+ }),
1156+ })(input).pipe(
1157+ Effect.provideService(
1158+ RuntimeLocalWorkspaceService,
1159+ runtimeLocalWorkspace,
1160+ ),
1161+ );
11131162
11141163 describe("workspace-execution-environment", () => {
11151164 it.scoped("discovers persisted tools without live MCP listing", () =>
@@ -1118,7 +1167,8 @@ describe("workspace-execution-environment", () => { · 2 unmodified lines
11181167 const persistence = yield* makePersistence;
11191168 const workspaceId = WorkspaceIdSchema.make("ws_catalog");
11201169 const sourceId = SourceIdSchema.make("src_catalog");
1121− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1170+ const runtimeLocalWorkspace =
1171+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
11221172 yield* persistConnectedEchoTool({
11231173 runtimeLocalWorkspace,
11241174 endpoint: server.endpoint,
@@ -1132,7 +1182,10 @@ describe("workspace-execution-environment", () => { · 7 unmodified lines
11321182 cause instanceof Error ? cause : new Error(String(cause)),
11331183 }).pipe(Effect.orDie);
11341184
1135− const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1185+ const resolveEnvironment = makeResolver(
1186+ persistence,
1187+ runtimeLocalWorkspace,
1188+ );
11361189 const environment = yield* resolveEnvironment({
11371190 workspaceId,
11381191 accountId: AccountIdSchema.make("acc_catalog"),
@@ -1165,14 +1218,18 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines
11651218 const persistence = yield* makePersistence;
11661219 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");
11671220 const sourceId = SourceIdSchema.make("src_github_discovery");
1168− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1221+ const runtimeLocalWorkspace =
1222+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
11691223 yield* persistConnectedGithubOpenApiSource({
11701224 runtimeLocalWorkspace,
11711225 workspaceId,
11721226 sourceId,
11731227 });
11741228
1175− const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1229+ const resolveEnvironment = makeResolver(
1230+ persistence,
1231+ runtimeLocalWorkspace,
1232+ );
11761233 const environment = yield* resolveEnvironment({
11771234 workspaceId,
11781235 accountId: AccountIdSchema.make("acc_github_discovery"),
@@ -1219,7 +1276,8 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
12191276 const persistence = yield* makePersistence;
12201277 const workspaceId = WorkspaceIdSchema.make("ws_invoke");
12211278 const sourceId = SourceIdSchema.make("src_invoke");
1222− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1279+ const runtimeLocalWorkspace =
1280+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
12231281 yield* persistConnectedEchoTool({
12241282 runtimeLocalWorkspace,
12251283 endpoint: server.endpoint,
@@ -1227,7 +1285,10 @@ describe("workspace-execution-environment", () => { · 1 unmodified line
12271285 sourceId,
12281286 });
12291287
1230− const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1288+ const resolveEnvironment = makeResolver(
1289+ persistence,
1290+ runtimeLocalWorkspace,
1291+ );
12311292 const environment = yield* resolveEnvironment({
12321293 workspaceId,
12331294 accountId: AccountIdSchema.make("acc_invoke"),
@@ -1249,6 +1310,126 @@ describe("workspace-execution-environment", () => { · 15 unmodified lines
12491310 }),
12501311 );
12511312
1313+ it.scoped(
1314+ "discovers and invokes local TypeScript tools from grouped directories",
1315+ () =>
1316+ Effect.gen(function* () {
1317+ const persistence = yield* makePersistence;
1318+ const workspaceId = WorkspaceIdSchema.make("ws_local_tools");
1319+ const runtimeLocalWorkspace =
1320+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
1321+ yield* writeLocalToolSource({
1322+ runtimeLocalWorkspace,
1323+ relativePath: join("math", "arithmetic", "add.ts"),
1324+ content: `
1325+import { Schema } from "effect"
1326+
1327+const inputSchema = Schema.standardSchemaV1(
1328+ Schema.Struct({
1329+ a: Schema.Number,
1330+ b: Schema.Number,
1331+ }),
1332+)
1333+
1334+const outputSchema = Schema.standardSchemaV1(
1335+ Schema.Struct({
1336+ sum: Schema.Number,
1337+ }),
1338+)
1339+
1340+export default {
1341+ description: "Add two numbers together",
1342+ inputSchema,
1343+ outputSchema,
1344+ async execute({ a, b }: { a: number; b: number }) {
1345+ await Promise.resolve()
1346+ return { sum: a + b }
1347+ },
1348+}
1349+`.trimStart(),
1350+ });
1351+ yield* writeLocalToolSource({
1352+ runtimeLocalWorkspace,
1353+ relativePath: join("text", "case", "upper.ts"),
1354+ content: `
1355+import { defineLocalTool } from "@executor/control-plane"
1356+import * as z from "zod/v4"
1357+
1358+const inputSchema = z.object({
1359+ value: z.string(),
1360+})
1361+
1362+const outputSchema = z.object({
1363+ value: z.string(),
1364+})
1365+
1366+export const tool = defineLocalTool({
1367+ description: "Convert text to uppercase",
1368+ inputSchema,
1369+ outputSchema,
1370+ async execute({ value }) {
1371+ await Promise.resolve()
1372+ return { value: value.toUpperCase() }
1373+ },
1374+})
1375+`.trimStart(),
1376+ });
1377+
1378+ const resolveEnvironment = makeResolver(
1379+ persistence,
1380+ runtimeLocalWorkspace,
1381+ );
1382+ const environment = yield* resolveEnvironment({
1383+ workspaceId,
1384+ accountId: AccountIdSchema.make("acc_local_tools"),
1385+ executionId: ExecutionIdSchema.make("exec_local_tools"),
1386+ });
1387+
1388+ const discovered = (yield* environment.toolInvoker.invoke({
1389+ path: "discover",
1390+ args: {
1391+ query: "add two numbers arithmetic",
1392+ limit: 5,
1393+ },
1394+ })) as {
1395+ bestPath: string | null;
1396+ results: Array<{ path: string }>;
1397+ };
1398+
1399+ expect(discovered.bestPath).toBe("math.arithmetic.add");
1400+ expect(discovered.results[0]?.path).toBe("math.arithmetic.add");
1401+
1402+ const listed = yield* environment.catalog!.listTools({
1403+ namespace: "math.arithmetic",
1404+ limit: 5,
1405+ includeSchemas: false,
1406+ });
1407+
1408+ expect(listed).toHaveLength(1);
1409+ expect(listed[0]?.path).toBe("math.arithmetic.add");
1410+ expect(listed[0]?.sourceKey).toBe("local.tool.math.arithmetic.add");
1411+
1412+ const result = (yield* environment.toolInvoker.invoke({
1413+ path: "math.arithmetic.add",
1414+ args: {
1415+ a: 2,
1416+ b: 3,
1417+ },
1418+ })) as { sum: number };
1419+
1420+ expect(result.sum).toBe(5);
1421+
1422+ const upper = (yield* environment.toolInvoker.invoke({
1423+ path: "text.case.upper",
1424+ args: {
1425+ value: "hello",
1426+ },
1427+ })) as { value: string };
1428+
1429+ expect(upper.value).toBe("HELLO");
1430+ }),
1431+ );
1432+
12521433 it.scoped(
12531434 "adds an OpenAPI source through executor.sources.add with elicited credentials",
12541435 () =>
@@ -1256,8 +1437,12 @@ describe("workspace-execution-environment", () => { · 1 unmodified line
12561437 const specServer = yield* makeOpenApiSpecServer;
12571438 const persistence = yield* makePersistence;
12581439 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
1259− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1260− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
1440+ const runtimeLocalWorkspace =
1441+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
1442+ const resolveEnvironment = makeResolverWithLiveSourceAuth(
1443+ persistence,
1444+ runtimeLocalWorkspace,
1445+ );
12611446 const now = Date.now();
12621447 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
12631448 "sec_test_openapi_bearer",
@@ -1338,7 +1523,9 @@ describe("workspace-execution-environment", () => { · 74 unmodified lines
13381523 encodeURIComponent("exec_add_openapi:executor.sources.add:"),
13391524 );
13401525
1341− const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context);
1526+ const loadedConfig = yield* loadLocalExecutorConfig(
1527+ runtimeLocalWorkspace.context,
1528+ );
13421529 expect(loadedConfig.config?.sources?.[added.id]).toMatchObject({
13431530 kind: "openapi",
13441531 connection: {
@@ -1352,7 +1539,9 @@ describe("workspace-execution-environment", () => { · 7 unmodified lines
13521539 expect(artifact?.documents[0]?.contentText).toContain(
13531540 '"operationId":"repos.getRepo"',
13541541 );
1355− const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);
1542+ const workspaceState = yield* loadLocalWorkspaceState(
1543+ runtimeLocalWorkspace.context,
1544+ );
13561545 expect(workspaceState.sources[added.id]?.status).toBe("connected");
13571546
13581547 const freshEnvironment = yield* resolveEnvironment({
@@ -1399,8 +1588,12 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
13991588 const graphqlServer = yield* makeGraphqlServer;
14001589 const persistence = yield* makePersistence;
14011590 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");
1402− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1403− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
1591+ const runtimeLocalWorkspace =
1592+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
1593+ const resolveEnvironment = makeResolverWithLiveSourceAuth(
1594+ persistence,
1595+ runtimeLocalWorkspace,
1596+ );
14041597 const now = Date.now();
14051598 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
14061599 "sec_test_graphql_bearer",
@@ -1630,7 +1823,8 @@ describe("workspace-execution-environment", () => { · 223 unmodified lines
16301823 const persistence = yield* makePersistence;
16311824 const workspaceId = WorkspaceIdSchema.make("ws_params_auth");
16321825 const sourceId = SourceIdSchema.make("src_params_auth");
1633− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1826+ const runtimeLocalWorkspace =
1827+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
16341828
16351829 yield* persistConnectedGithubOpenApiSource({
16361830 runtimeLocalWorkspace,
@@ -1643,7 +1837,10 @@ describe("workspace-execution-environment", () => { · 6 unmodified lines
16431837 },
16441838 });
16451839
1646− const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1840+ const resolveEnvironment = makeResolver(
1841+ persistence,
1842+ runtimeLocalWorkspace,
1843+ );
16471844 const environment = yield* resolveEnvironment({
16481845 workspaceId,
16491846 accountId: AccountIdSchema.make("acc_params_auth"),
@@ -1683,7 +1880,8 @@ describe("workspace-execution-environment", () => { · 33 unmodified lines
16831880 "ws_openapi_describe_bundle",
16841881 );
16851882 const sourceId = SourceIdSchema.make("src_openapi_describe_bundle");
1686− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1883+ const runtimeLocalWorkspace =
1884+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
16871885
16881886 yield* persistConnectedGithubOpenApiSource({
16891887 runtimeLocalWorkspace,
@@ -1691,7 +1889,10 @@ describe("workspace-execution-environment", () => { · 1 unmodified line
16911889 sourceId,
16921890 });
16931891
1694− const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1892+ const resolveEnvironment = makeResolver(
1893+ persistence,
1894+ runtimeLocalWorkspace,
1895+ );
16951896 const environment = yield* resolveEnvironment({
16961897 workspaceId,
16971898 accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),
@@ -1739,8 +1940,12 @@ describe("workspace-execution-environment", () => { · 41 unmodified lines
17391940 Effect.gen(function* () {
17401941 const persistence = yield* makePersistence;
17411942 const workspaceId = WorkspaceIdSchema.make("ws_describe_add_source");
1742− const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1743− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
1943+ const runtimeLocalWorkspace =
1944+ yield* makeRuntimeLocalWorkspaceState(workspaceId);
1945+ const resolveEnvironment = makeResolverWithLiveSourceAuth(
1946+ persistence,
1947+ runtimeLocalWorkspace,
1948+ );
17441949
17451950 const environment = yield* resolveEnvironment({
17461951 workspaceId,
packages/control-plane/src/runtime/workspace-execution-environment.ts+279−185
@@ -2,8 +2,10 @@ import { · 1 unmodified line
22 createSystemToolMap,
33 createToolCatalogFromTools,
44 makeToolInvokerFromTools,
5+ mergeToolMaps,
56 type SearchHit,
67 type ToolCatalog,
8+ type ToolDescriptor,
79 type ToolInvoker,
810 type ToolNamespace,
911 type ToolPath,
@@ -51,14 +53,13 @@ import { · 41 unmodified lines
5153 evaluateInvocationPolicy,
5254 type InvocationDescriptor,
5355 } from "./invocation-policy-engine";
54−import {
55− loadRuntimeLocalWorkspacePolicies,
56−} from "./policies-operations";
56+import { loadRuntimeLocalWorkspacePolicies } from "./policies-operations";
5757 import {
5858 getRuntimeLocalWorkspaceOption,
5959 RuntimeLocalWorkspaceService,
6060 type RuntimeLocalWorkspaceState,
6161 } from "./local-runtime-context";
62+import { loadLocalToolRuntime, type LocalToolRuntime } from "./local-tools";
6263
6364 const asToolPath = (value: string): ToolPath => value as ToolPath;
6465
@@ -205,10 +206,19 @@ const loadWorkspaceSchemaBundle = (input: { · 140 unmodified lines
205206 : null;
206207 }
207208
208− const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(input.id);
209+ const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(
210+ input.id,
211+ );
209212 if (Option.isSome(bundle)) {
210− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
211− if (sourceRecords.some((sourceRecord) => sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId)) {
213+ const sourceRecords = yield* input.rows.sources.listByWorkspaceId(
214+ input.workspaceId,
215+ );
216+ if (
217+ sourceRecords.some(
218+ (sourceRecord) =>
219+ sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,
220+ )
221+ ) {
212222 return {
213223 id: bundle.value.id,
214224 kind: bundle.value.bundleKind,
@@ -386,7 +396,9 @@ const authorizePersistedToolInvocation = (input: { · 171 unmodified lines
386396 >[0]["onElicitation"];
387397 }): Effect.Effect<void, Error, never> =>
388398 Effect.gen(function* () {
389− const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
399+ const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(
400+ input.workspaceId,
401+ ).pipe(
390402 Effect.mapError((cause) =>
391403 cause instanceof Error ? cause : new Error(String(cause)),
392404 ),
@@ -474,151 +486,205 @@ const provideRuntimeLocalWorkspace = <A, E, R>( · 81 unmodified lines
474486 ),
475487 );
476488
489+const dedupeToolDescriptors = (
490+ preferred: readonly ToolDescriptor[],
491+ fallback: readonly ToolDescriptor[],
492+): ToolDescriptor[] => {
493+ const merged = new Map<string, ToolDescriptor>();
494+
495+ for (const descriptor of preferred) {
496+ merged.set(descriptor.path, descriptor);
497+ }
498+ for (const descriptor of fallback) {
499+ if (!merged.has(descriptor.path)) {
500+ merged.set(descriptor.path, descriptor);
501+ }
502+ }
503+
504+ return [...merged.values()];
505+};
506+
507+const dedupeSearchHits = (
508+ preferred: readonly SearchHit[],
509+ fallback: readonly SearchHit[],
510+): SearchHit[] => {
511+ const merged = new Map<string, SearchHit>();
512+
513+ for (const hit of preferred) {
514+ merged.set(hit.path, hit);
515+ }
516+ for (const hit of fallback) {
517+ if (!merged.has(hit.path)) {
518+ merged.set(hit.path, hit);
519+ }
520+ }
521+
522+ return [...merged.values()];
523+};
524+
477525 const createWorkspaceToolCatalog = (input: {
478526 workspaceId: Source["workspaceId"];
479527 accountId: AccountId;
480528 rows: SqlControlPlaneRows;
481− executorCatalog: ToolCatalog;
529+ authoredCatalog: ToolCatalog;
482530 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
483531 }): ToolCatalog => ({
484532 listNamespaces: ({ limit }) =>
485− provideRuntimeLocalWorkspace(Effect.gen(function* () {
486− const [recipeTools, executor] = yield* Effect.all([
487− loadWorkspaceRecipeTools({
488− rows: input.rows,
489− workspaceId: input.workspaceId,
490− accountId: input.accountId,
491− includeSchemas: false,
492− }),
493− input.executorCatalog.listNamespaces({ limit }),
494− ]);
495−
496− const merged = new Map<string, ToolNamespace>();
497− for (const tool of recipeTools) {
498− const existing = merged.get(tool.searchNamespace);
499− merged.set(tool.searchNamespace, {
500− namespace: tool.searchNamespace,
501− toolCount: (existing?.toolCount ?? 0) + 1,
502− });
503− }
504− for (const namespace of executor) {
505− const existing = merged.get(namespace.namespace);
506− merged.set(namespace.namespace, {
507− namespace: namespace.namespace,
508− displayName: namespace.displayName ?? existing?.displayName,
509− toolCount:
510− namespace.toolCount !== undefined ||
511− existing?.toolCount === undefined
512− ? namespace.toolCount
513− : existing.toolCount,
514− });
515− }
533+ provideRuntimeLocalWorkspace(
534+ Effect.gen(function* () {
535+ const [recipeTools, authored] = yield* Effect.all([
536+ loadWorkspaceRecipeTools({
537+ rows: input.rows,
538+ workspaceId: input.workspaceId,
539+ accountId: input.accountId,
540+ includeSchemas: false,
541+ }),
542+ input.authoredCatalog.listNamespaces({ limit }),
543+ ]);
544+
545+ const merged = new Map<string, ToolNamespace>();
546+ for (const tool of recipeTools) {
547+ const existing = merged.get(tool.searchNamespace);
548+ merged.set(tool.searchNamespace, {
549+ namespace: tool.searchNamespace,
550+ toolCount: (existing?.toolCount ?? 0) + 1,
551+ });
552+ }
553+ for (const namespace of authored) {
554+ const existing = merged.get(namespace.namespace);
555+ merged.set(namespace.namespace, {
556+ namespace: namespace.namespace,
557+ displayName: namespace.displayName ?? existing?.displayName,
558+ toolCount:
559+ namespace.toolCount !== undefined ||
560+ existing?.toolCount === undefined
561+ ? namespace.toolCount
562+ : existing.toolCount,
563+ });
564+ }
516565
517− return [...merged.values()]
518− .sort((left, right) => left.namespace.localeCompare(right.namespace))
519− .slice(0, limit);
520− }), input.runtimeLocalWorkspace),
566+ return [...merged.values()]
567+ .sort((left, right) => left.namespace.localeCompare(right.namespace))
568+ .slice(0, limit);
569+ }),
570+ input.runtimeLocalWorkspace,
571+ ),
521572
522573 listTools: ({ namespace, query, limit, includeSchemas = false }) =>
523− provideRuntimeLocalWorkspace(Effect.gen(function* () {
524− const [recipeTools, executor] = yield* Effect.all([
525− loadWorkspaceRecipeTools({
574+ provideRuntimeLocalWorkspace(
575+ Effect.gen(function* () {
576+ const [recipeTools, authored] = yield* Effect.all([
577+ loadWorkspaceRecipeTools({
578+ rows: input.rows,
579+ workspaceId: input.workspaceId,
580+ accountId: input.accountId,
581+ includeSchemas,
582+ }).pipe(
583+ Effect.map((tools) =>
584+ tools.filter((tool) => {
585+ if (namespace && tool.searchNamespace !== namespace) {
586+ return false;
587+ }
588+ if (!query) {
589+ return true;
590+ }
591+ return tokenize(query).every((token) =>
592+ tool.searchText.includes(token),
593+ );
594+ }),
595+ ),
596+ ),
597+ input.authoredCatalog.listTools({
598+ ...(namespace !== undefined ? { namespace } : {}),
599+ ...(query !== undefined ? { query } : {}),
600+ limit,
601+ includeSchemas,
602+ }),
603+ ]);
604+
605+ return dedupeToolDescriptors(
606+ authored,
607+ recipeTools.map((tool) => tool.descriptor),
608+ )
609+ .sort((left, right) => left.path.localeCompare(right.path))
610+ .slice(0, limit);
611+ }),
612+ input.runtimeLocalWorkspace,
613+ ),
614+
615+ getToolByPath: ({ path, includeSchemas }) =>
616+ provideRuntimeLocalWorkspace(
617+ Effect.gen(function* () {
618+ const authored = yield* input.authoredCatalog.getToolByPath({
619+ path,
620+ includeSchemas,
621+ });
622+ if (authored) {
623+ return authored;
624+ }
625+
626+ const recipeTool = yield* loadWorkspaceRecipeToolByPath({
526627 rows: input.rows,
527628 workspaceId: input.workspaceId,
528629 accountId: input.accountId,
630+ path,
529631 includeSchemas,
530− }).pipe(
531− Effect.map((tools) =>
532− tools.filter((tool) => {
533− if (namespace && tool.searchNamespace !== namespace) {
534− return false;
535− }
536− if (!query) {
537− return true;
538− }
539− return tokenize(query).every((token) =>
540− tool.searchText.includes(token),
541− );
542− }),
543− ),
544− ),
545− input.executorCatalog.listTools({
546− ...(namespace !== undefined ? { namespace } : {}),
547− ...(query !== undefined ? { query } : {}),
548− limit,
549− includeSchemas,
550− }),
551− ]);
552−
553− return [...recipeTools.map((tool) => tool.descriptor), ...executor]
554− .sort((left, right) => left.path.localeCompare(right.path))
555− .slice(0, limit);
556− }), input.runtimeLocalWorkspace),
557−
558− getToolByPath: ({ path, includeSchemas }) =>
559− provideRuntimeLocalWorkspace(Effect.gen(function* () {
560− const executor = yield* input.executorCatalog.getToolByPath({
561− path,
562− includeSchemas,
563− });
564− if (executor) {
565− return executor;
566− }
632+ });
633+ return recipeTool?.descriptor ?? null;
634+ }),
635+ input.runtimeLocalWorkspace,
636+ ),
567637
568− const recipeTool = yield* loadWorkspaceRecipeToolByPath({
638+ getSchemaBundle: ({ id }) =>
639+ provideRuntimeLocalWorkspace(
640+ loadWorkspaceSchemaBundle({
569641 rows: input.rows,
570642 workspaceId: input.workspaceId,
571− accountId: input.accountId,
572− path,
573− includeSchemas,
574− });
575− return recipeTool?.descriptor ?? null;
576− }), input.runtimeLocalWorkspace),
577−
578− getSchemaBundle: ({ id }) =>
579− provideRuntimeLocalWorkspace(loadWorkspaceSchemaBundle({
580− rows: input.rows,
581− workspaceId: input.workspaceId,
582− id: SourceRecipeSchemaBundleIdSchema.make(id),
583− }), input.runtimeLocalWorkspace),
643+ id: SourceRecipeSchemaBundleIdSchema.make(id),
644+ }),
645+ input.runtimeLocalWorkspace,
646+ ),
584647
585648 searchTools: ({ query, namespace, limit }) =>
586− provideRuntimeLocalWorkspace(Effect.gen(function* () {
587− const queryTokens = tokenize(query);
588− const [recipeTools, executor] = yield* Effect.all([
589− loadWorkspaceRecipeTools({
590− rows: input.rows,
591− workspaceId: input.workspaceId,
592− accountId: input.accountId,
593− includeSchemas: false,
594− }).pipe(
595− Effect.map((tools) =>
596− tools.filter(
597− (tool) => !namespace || tool.searchNamespace === namespace,
649+ provideRuntimeLocalWorkspace(
650+ Effect.gen(function* () {
651+ const queryTokens = tokenize(query);
652+ const [recipeTools, authored] = yield* Effect.all([
653+ loadWorkspaceRecipeTools({
654+ rows: input.rows,
655+ workspaceId: input.workspaceId,
656+ accountId: input.accountId,
657+ includeSchemas: false,
658+ }).pipe(
659+ Effect.map((tools) =>
660+ tools.filter(
661+ (tool) => !namespace || tool.searchNamespace === namespace,
662+ ),
598663 ),
599664 ),
600− ),
601− input.executorCatalog.searchTools({
602− query,
603− ...(namespace !== undefined ? { namespace } : {}),
604− limit,
605− }),
606− ]);
607−
608− const recipeHits: SearchHit[] = recipeTools
609− .map((tool) => ({
610− path: asToolPath(tool.path),
611− score: scoreRecipeTool(queryTokens, tool),
612− }))
613− .filter((hit) => hit.score > 0);
614−
615− return [...recipeHits, ...executor]
616− .sort(
617− (left, right) =>
618− right.score - left.score || left.path.localeCompare(right.path),
619− )
620− .slice(0, limit);
621− }), input.runtimeLocalWorkspace),
665+ input.authoredCatalog.searchTools({
666+ query,
667+ ...(namespace !== undefined ? { namespace } : {}),
668+ limit,
669+ }),
670+ ]);
671+
672+ const recipeHits: SearchHit[] = recipeTools
673+ .map((tool) => ({
674+ path: asToolPath(tool.path),
675+ score: scoreRecipeTool(queryTokens, tool),
676+ }))
677+ .filter((hit) => hit.score > 0);
678+
679+ return dedupeSearchHits(authored, recipeHits)
680+ .sort(
681+ (left, right) =>
682+ right.score - left.score || left.path.localeCompare(right.path),
683+ )
684+ .slice(0, limit);
685+ }),
686+ input.runtimeLocalWorkspace,
687+ ),
622688 });
623689
624690 const createWorkspaceToolInvoker = (input: {
@@ -628,6 +694,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines
628694 resolveSecretMaterial: ResolveSecretMaterial;
629695 sourceAuthService: RuntimeSourceAuthService;
630696 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
697+ localToolRuntime: LocalToolRuntime;
631698 onElicitation?: Parameters<
632699 typeof makeToolInvokerFromTools
633700 >[0]["onElicitation"];
@@ -641,25 +708,34 @@ const createWorkspaceToolInvoker = (input: { · 7 unmodified lines
641708 sourceAuthService: input.sourceAuthService,
642709 runtimeLocalWorkspace: input.runtimeLocalWorkspace,
643710 });
644− const executorCatalog = createToolCatalogFromTools({
645− tools: executorTools,
711+ const authoredTools = mergeToolMaps([
712+ executorTools,
713+ input.localToolRuntime.tools,
714+ ]);
715+ const authoredCatalog = createToolCatalogFromTools({
716+ tools: authoredTools,
646717 });
647718 const catalog = createWorkspaceToolCatalog({
648719 workspaceId: input.workspaceId,
649720 accountId: input.accountId,
650721 rows: input.rows,
651− executorCatalog,
722+ authoredCatalog,
652723 runtimeLocalWorkspace: input.runtimeLocalWorkspace,
653724 });
654725 const systemTools = createSystemToolMap({ catalog });
655726 const systemToolPaths = new Set(Object.keys(systemTools));
656− const executorToolPaths = new Set(Object.keys(executorTools));
727+ const authoredToolPaths = new Set(Object.keys(authoredTools));
728+ for (const systemToolPath of systemToolPaths) {
729+ if (authoredToolPaths.has(systemToolPath)) {
730+ throw new Error(`Tool path conflict: ${systemToolPath}`);
731+ }
732+ }
657733 const systemInvoker = makeToolInvokerFromTools({
658734 tools: systemTools,
659735 onElicitation: input.onElicitation,
660736 });
661− const executorInvoker = makeToolInvokerFromTools({
662− tools: executorTools,
737+ const authoredInvoker = makeToolInvokerFromTools({
738+ tools: authoredTools,
663739 onElicitation: input.onElicitation,
664740 });
665741
@@ -668,54 +744,60 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines
668744 args: unknown;
669745 context?: Record<string, unknown>;
670746 }) =>
671− provideRuntimeLocalWorkspace(Effect.gen(function* () {
672− const recipeTool = yield* loadWorkspaceRecipeToolByPath({
673− rows: input.rows,
674− workspaceId: input.workspaceId,
675− accountId: input.accountId,
676− path: invocation.path,
677− includeSchemas: false,
678− });
679− if (!recipeTool) {
680− return yield* Effect.fail(
681− new Error(`Unknown tool path: ${invocation.path}`),
682− );
683− }
747+ provideRuntimeLocalWorkspace(
748+ Effect.gen(function* () {
749+ const recipeTool = yield* loadWorkspaceRecipeToolByPath({
750+ rows: input.rows,
751+ workspaceId: input.workspaceId,
752+ accountId: input.accountId,
753+ path: invocation.path,
754+ includeSchemas: false,
755+ });
756+ if (!recipeTool) {
757+ return yield* Effect.fail(
758+ new Error(`Unknown tool path: ${invocation.path}`),
759+ );
760+ }
684761
685− yield* authorizePersistedToolInvocation({
686− rows: input.rows,
687− workspaceId: input.workspaceId,
688− accountId: input.accountId,
689− descriptor: toInvocationDescriptorFromRecipeTool({ tool: recipeTool }),
690− args: invocation.args,
691− source: recipeTool.source,
692− context: invocation.context,
693− onElicitation: input.onElicitation,
694− });
762+ yield* authorizePersistedToolInvocation({
763+ rows: input.rows,
764+ workspaceId: input.workspaceId,
765+ accountId: input.accountId,
766+ descriptor: toInvocationDescriptorFromRecipeTool({
767+ tool: recipeTool,
768+ }),
769+ args: invocation.args,
770+ source: recipeTool.source,
771+ context: invocation.context,
772+ onElicitation: input.onElicitation,
773+ });
695774
696− const auth = yield* resolveSourceAuthMaterial({
697− rows: input.rows,
698− source: recipeTool.source,
699− actorAccountId: input.accountId,
700− resolveSecretMaterial: input.resolveSecretMaterial,
701− context: toSecretResolutionContext(invocation.context),
702− });
703− const schemaBundle = recipeTool.schemaBundleId
704− ? yield* loadWorkspaceSchemaBundle({
705− rows: input.rows,
706− workspaceId: input.workspaceId,
707− id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),
708− })
709− : null;
710− const recipe = yield* loadSourceWithRecipe({
711− rows: input.rows,
712− workspaceId: input.workspaceId,
713− sourceId: recipeTool.source.id,
714− actorAccountId: input.accountId,
715− });
775+ const auth = yield* resolveSourceAuthMaterial({
776+ rows: input.rows,
777+ source: recipeTool.source,
778+ actorAccountId: input.accountId,
779+ resolveSecretMaterial: input.resolveSecretMaterial,
780+ context: toSecretResolutionContext(invocation.context),
781+ });
782+ const schemaBundle = recipeTool.schemaBundleId
783+ ? yield* loadWorkspaceSchemaBundle({
784+ rows: input.rows,
785+ workspaceId: input.workspaceId,
786+ id: SourceRecipeSchemaBundleIdSchema.make(
787+ recipeTool.schemaBundleId,
788+ ),
789+ })
790+ : null;
791+ const recipe = yield* loadSourceWithRecipe({
792+ rows: input.rows,
793+ workspaceId: input.workspaceId,
794+ sourceId: recipeTool.source.id,
795+ actorAccountId: input.accountId,
796+ });
716797
717− return yield* getSourceAdapterForOperation(recipeTool.operation)
718− .invokePersistedTool({
798+ return yield* getSourceAdapterForOperation(
799+ recipeTool.operation,
800+ ).invokePersistedTool({
719801 rows: input.rows,
720802 workspaceId: input.workspaceId,
721803 accountId: input.accountId,
@@ -729,7 +811,9 @@ const createWorkspaceToolInvoker = (input: { · 7 unmodified lines
729811 context: invocation.context,
730812 onElicitation: input.onElicitation,
731813 });
732− }), input.runtimeLocalWorkspace);
814+ }),
815+ input.runtimeLocalWorkspace,
816+ );
733817
734818 return {
735819 catalog,
@@ -738,8 +822,8 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines
738822 provideRuntimeLocalWorkspace(
739823 systemToolPaths.has(path)
740824 ? systemInvoker.invoke({ path, args, context })
741− : executorToolPaths.has(path)
742− ? executorInvoker.invoke({ path, args, context })
825+ : authoredToolPaths.has(path)
826+ ? authoredInvoker.invoke({ path, args, context })
743827 : invokePersistedTool({ path, args, context }),
744828 input.runtimeLocalWorkspace,
745829 ),
@@ -761,6 +845,15 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 15 unmodified lines
761845 return ({ workspaceId, accountId, onElicitation }) =>
762846 Effect.gen(function* () {
763847 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
848+ const localToolRuntime =
849+ runtimeLocalWorkspace === null
850+ ? {
851+ tools: {},
852+ catalog: createToolCatalogFromTools({ tools: {} }),
853+ toolInvoker: makeToolInvokerFromTools({ tools: {} }),
854+ toolPaths: new Set<string>(),
855+ }
856+ : yield* loadLocalToolRuntime(runtimeLocalWorkspace.context);
764857 const { catalog, toolInvoker } = createWorkspaceToolInvoker({
765858 workspaceId,
766859 accountId,
@@ -768,6 +861,7 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 1 unmodified line
768861 resolveSecretMaterial,
769862 sourceAuthService: input.sourceAuthService,
770863 runtimeLocalWorkspace,
864+ localToolRuntime,
771865 onElicitation,
772866 });
773867