History

Add local file-backed workspace tools

Rhys Sullivan committed 8 months ago

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

Showing the changes to packages (6 of 6 files). Show all

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
37 "graphql": "^16.12.0",37 "graphql": "^16.12.0",
38 "jsonc-parser": "3.3.1",38 "jsonc-parser": "3.3.1",
39 "postgres": "^3.4.7",39 "postgres": "^3.4.7",
40 "prettier": "^3.8.1"40 "prettier": "^3.8.1",
41 "typescript": "^5.9.3"
41 },42 },
42 "devDependencies": {43 "devDependencies": {
43 "@effect/vitest": "catalog:",44 "@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
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Scope from "effect/Scope";5import * as Scope from "effect/Scope";
6 6
7import {7import { type ControlPlaneApiRuntimeContext } from "#api";
8 type ControlPlaneApiRuntimeContext,
9} from "#api";
10import {8import {
11 SqlControlPlanePersistenceLive,9 SqlControlPlanePersistenceLive,
12 SqlControlPlanePersistenceService,10 SqlControlPlanePersistenceService,
@@ -18,9 +16,7 @@ import { · 5 unmodified lines
18} from "#persistence";16} from "#persistence";
19import type { LocalInstallation } from "#schema";17import type { LocalInstallation } from "#schema";
20 18
21import {19import { type ResolveExecutionEnvironment } from "./execution-state";
22 type ResolveExecutionEnvironment,
23} from "./execution-state";
24import {20import {
25 createLiveExecutionManager,21 createLiveExecutionManager,
26 LiveExecutionManagerLive,22 LiveExecutionManagerLive,
@@ -33,10 +29,7 @@ import { · 6 unmodified lines
33} from "./local-config";29} from "./local-config";
34import { RuntimeLocalWorkspaceService } from "./local-runtime-context";30import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
35import { synchronizeLocalWorkspaceState } from "./local-workspace-sync";31import { synchronizeLocalWorkspaceState } from "./local-workspace-sync";
36import {32import { ControlPlaneStore, ControlPlaneStoreLive } from "./store";
37 ControlPlaneStore,
38 ControlPlaneStoreLive,
39} from "./store";
40import {33import {
41 createRuntimeSourceAuthService,34 createRuntimeSourceAuthService,
42 RuntimeSourceAuthServiceLive,35 RuntimeSourceAuthServiceLive,
@@ -56,6 +49,7 @@ export * from "./live-execution"; · 13 unmodified lines
56export * from "./local-config";49export * from "./local-config";
57export * from "./local-installation";50export * from "./local-installation";
58export * from "./local-source-artifacts";51export * from "./local-source-artifacts";
52export * from "./local-tools";
59export * from "./schema-type-signature";53export * from "./schema-type-signature";
60export * from "./source-auth-service";54export * from "./source-auth-service";
61export * from "./secret-material-providers";55export * from "./secret-material-providers";
@@ -106,7 +100,7 @@ const runtimeContextTags = [ · 44 unmodified lines
106 100
107const createRuntimeLayerFromContext = (101const createRuntimeLayerFromContext = (
108 context: Context.Context<102 context: Context.Context<
109 ControlPlaneStore103 | ControlPlaneStore
110 | LiveExecutionManagerService104 | LiveExecutionManagerService
111 | RuntimeLocalWorkspaceService105 | RuntimeLocalWorkspaceService
112 | RuntimeSourceAuthServiceTag106 | RuntimeSourceAuthServiceTag
@@ -116,7 +110,7 @@ const createRuntimeLayerFromContext = ( · 3 unmodified lines
116 const runtimeContext = context.pipe(110 const runtimeContext = context.pipe(
117 Context.pick(...runtimeContextTags),111 Context.pick(...runtimeContextTags),
118 ) as Context.Context<112 ) as Context.Context<
119 ControlPlaneStore113 | ControlPlaneStore
120 | LiveExecutionManagerService114 | LiveExecutionManagerService
121 | RuntimeLocalWorkspaceService115 | RuntimeLocalWorkspaceService
122 | RuntimeSourceAuthServiceTag116 | RuntimeSourceAuthServiceTag
@@ -132,15 +126,11 @@ export const createRuntimeControlPlaneLayer = ( · 9 unmodified lines
132 const liveExecutionManagerLayer = LiveExecutionManagerLive;126 const liveExecutionManagerLayer = LiveExecutionManagerLive;
133 const sourceAuthLayer = RuntimeSourceAuthServiceLive({127 const sourceAuthLayer = RuntimeSourceAuthServiceLive({
134 getLocalServerBaseUrl: options.getLocalServerBaseUrl,128 getLocalServerBaseUrl: options.getLocalServerBaseUrl,
135 }).pipe(129 }).pipe(Layer.provide(liveExecutionManagerLayer));
136 Layer.provide(liveExecutionManagerLayer),
137 );
138 const executionResolverLayer = RuntimeExecutionResolverLive({130 const executionResolverLayer = RuntimeExecutionResolverLive({
139 executionResolver: options.executionResolver,131 executionResolver: options.executionResolver,
140 resolveSecretMaterial: options.resolveSecretMaterial,132 resolveSecretMaterial: options.resolveSecretMaterial,
141 }).pipe(133 }).pipe(Layer.provide(sourceAuthLayer));
142 Layer.provide(sourceAuthLayer),
143 );
144 134
145 return Layer.mergeAll(135 return Layer.mergeAll(
146 ControlPlaneStoreLive,136 ControlPlaneStoreLive,
@@ -157,8 +147,8 @@ export type ControlPlaneRuntime = { · 10 unmodified lines
157 close: () => Promise<void>;147 close: () => Promise<void>;
158};148};
159 149
160export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions150export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions &
161 & RuntimeControlPlaneOptions;151 RuntimeControlPlaneOptions;
162 152
163export const createControlPlaneRuntime = (153export const createControlPlaneRuntime = (
164 options: CreateControlPlaneRuntimeOptions,154 options: CreateControlPlaneRuntimeOptions,
@@ -168,14 +158,19 @@ export const createControlPlaneRuntime = ( · 3 unmodified lines
168 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(158 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(
169 Layer.provideMerge(SqlControlPlanePersistenceLive(options)),159 Layer.provideMerge(SqlControlPlanePersistenceLive(options)),
170 );160 );
171 const baseContext = yield* Layer.buildWithScope(persistenceAndRowsLayer, scope).pipe(161 const baseContext = yield* Layer.buildWithScope(
162 persistenceAndRowsLayer,
163 scope,
164 ).pipe(
172 Effect.catchAll((error) =>165 Effect.catchAll((error) =>
173 closeScope(scope).pipe(166 closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
174 Effect.zipRight(Effect.fail(error)),167 ),
175 )),
176 );168 );
177 169
178 const persistence = Context.get(baseContext, SqlControlPlanePersistenceService);170 const persistence = Context.get(
171 baseContext,
172 SqlControlPlanePersistenceService,
173 );
179 const rows = Context.get(baseContext, SqlControlPlaneRowsService);174 const rows = Context.get(baseContext, SqlControlPlaneRowsService);
180 175
181 const localWorkspaceContext = yield* resolveLocalWorkspaceContext({176 const localWorkspaceContext = yield* resolveLocalWorkspaceContext({
@@ -183,9 +178,8 @@ export const createControlPlaneRuntime = ( · 1 unmodified line
183 }).pipe(178 }).pipe(
184 Effect.mapError(toLocalRuntimeBootstrapError),179 Effect.mapError(toLocalRuntimeBootstrapError),
185 Effect.catchAll((error) =>180 Effect.catchAll((error) =>
186 closeScope(scope).pipe(181 closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
187 Effect.zipRight(Effect.fail(error)),182 ),
188 )),
189 );183 );
190 184
191 const localInstallation = yield* getOrProvisionLocalInstallation({185 const localInstallation = yield* getOrProvisionLocalInstallation({
@@ -193,17 +187,17 @@ export const createControlPlaneRuntime = ( · 1 unmodified line
193 }).pipe(187 }).pipe(
194 Effect.mapError(toLocalRuntimeBootstrapError),188 Effect.mapError(toLocalRuntimeBootstrapError),
195 Effect.catchAll((error) =>189 Effect.catchAll((error) =>
196 closeScope(scope).pipe(190 closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
197 Effect.zipRight(Effect.fail(error)),191 ),
198 )),
199 );192 );
200 193
201 const loadedLocalConfig = yield* loadLocalExecutorConfig(localWorkspaceContext).pipe(194 const loadedLocalConfig = yield* loadLocalExecutorConfig(
195 localWorkspaceContext,
196 ).pipe(
202 Effect.mapError(toLocalRuntimeBootstrapError),197 Effect.mapError(toLocalRuntimeBootstrapError),
203 Effect.catchAll((error) =>198 Effect.catchAll((error) =>
204 closeScope(scope).pipe(199 closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
205 Effect.zipRight(Effect.fail(error)),200 ),
206 )),
207 );201 );
208 202
209 const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({203 const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({
@@ -212,14 +206,13 @@ export const createControlPlaneRuntime = ( · 2 unmodified lines
212 }).pipe(206 }).pipe(
213 Effect.mapError(toLocalRuntimeBootstrapError),207 Effect.mapError(toLocalRuntimeBootstrapError),
214 Effect.catchAll((error) =>208 Effect.catchAll((error) =>
215 closeScope(scope).pipe(209 closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
216 Effect.zipRight(Effect.fail(error)),210 ),
217 )),
218 );211 );
219 212
220 const resolveSecretMaterial =213 const resolveSecretMaterial =
221 options.resolveSecretMaterial214 options.resolveSecretMaterial ??
222 ?? createDefaultSecretMaterialResolver({215 createDefaultSecretMaterialResolver({
223 rows,216 rows,
224 localConfig: effectiveLocalConfig,217 localConfig: effectiveLocalConfig,
225 workspaceRoot: localWorkspaceContext.workspaceRoot,218 workspaceRoot: localWorkspaceContext.workspaceRoot,
@@ -245,8 +238,8 @@ export const createControlPlaneRuntime = ( · 19 unmodified lines
245 },238 },
246 });239 });
247 const executionResolver =240 const executionResolver =
248 options.executionResolver241 options.executionResolver ??
249 ?? createWorkspaceExecutionEnvironmentResolver({242 createWorkspaceExecutionEnvironmentResolver({
250 rows,243 rows,
251 sourceAuthService,244 sourceAuthService,
252 resolveSecretMaterial,245 resolveSecretMaterial,
@@ -275,6 +268,7 @@ export const createControlPlaneRuntime = ( · 22 unmodified lines
275 persistence,268 persistence,
276 localInstallation,269 localInstallation,
277 runtimeLayer: concreteRuntimeLayer,270 runtimeLayer: concreteRuntimeLayer,
278 close: () => Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => {}),271 close: () =>
272 Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => {}),
279 };273 };
280 });274 });
packages/control-plane/src/runtime/local-errors.ts+60−9
@@ -3,52 +3,103 @@ import * as Data from "effect/Data"; · 2 unmodified lines
3export const unknownLocalErrorDetails = (cause: unknown): string =>3export const unknownLocalErrorDetails = (cause: unknown): string =>
4 cause instanceof Error ? cause.message : String(cause);4 cause instanceof Error ? cause.message : String(cause);
5 5
6export class LocalFileSystemError extends Data.TaggedError("LocalFileSystemError")<{6export class LocalFileSystemError extends Data.TaggedError(
7 "LocalFileSystemError",
8)<{
7 readonly message: string;9 readonly message: string;
8 readonly action: string;10 readonly action: string;
9 readonly path: string;11 readonly path: string;
10 readonly details: string;12 readonly details: string;
11}> {}13}> {}
12 14
13export class LocalExecutorConfigDecodeError extends Data.TaggedError("LocalExecutorConfigDecodeError")<{15export class LocalExecutorConfigDecodeError extends Data.TaggedError(
16 "LocalExecutorConfigDecodeError",
17)<{
14 readonly message: string;18 readonly message: string;
15 readonly path: string;19 readonly path: string;
16 readonly details: string;20 readonly details: string;
17}> {}21}> {}
18 22
19export class LocalWorkspaceStateDecodeError extends Data.TaggedError("LocalWorkspaceStateDecodeError")<{23export class LocalWorkspaceStateDecodeError extends Data.TaggedError(
24 "LocalWorkspaceStateDecodeError",
25)<{
20 readonly message: string;26 readonly message: string;
21 readonly path: string;27 readonly path: string;
22 readonly details: string;28 readonly details: string;
23}> {}29}> {}
24 30
25export class LocalSourceArtifactDecodeError extends Data.TaggedError("LocalSourceArtifactDecodeError")<{31export class LocalSourceArtifactDecodeError extends Data.TaggedError(
32 "LocalSourceArtifactDecodeError",
33)<{
26 readonly message: string;34 readonly message: string;
27 readonly path: string;35 readonly path: string;
28 readonly details: string;36 readonly details: string;
29}> {}37}> {}
30 38
31export class RuntimeLocalWorkspaceUnavailableError extends Data.TaggedError("RuntimeLocalWorkspaceUnavailableError")<{39export class LocalToolTranspileError extends Data.TaggedError(
40 "LocalToolTranspileError",
41)<{
42 readonly message: string;
43 readonly path: string;
44 readonly details: string;
45}> {}
46
47export class LocalToolImportError extends Data.TaggedError(
48 "LocalToolImportError",
49)<{
50 readonly message: string;
51 readonly path: string;
52 readonly details: string;
53}> {}
54
55export class LocalToolDefinitionError extends Data.TaggedError(
56 "LocalToolDefinitionError",
57)<{
58 readonly message: string;
59 readonly path: string;
60 readonly details: string;
61}> {}
62
63export 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
72export class RuntimeLocalWorkspaceUnavailableError extends Data.TaggedError(
73 "RuntimeLocalWorkspaceUnavailableError",
74)<{
32 readonly message: string;75 readonly message: string;
33}> {}76}> {}
34 77
35export class RuntimeLocalWorkspaceMismatchError extends Data.TaggedError("RuntimeLocalWorkspaceMismatchError")<{78export class RuntimeLocalWorkspaceMismatchError extends Data.TaggedError(
79 "RuntimeLocalWorkspaceMismatchError",
80)<{
36 readonly message: string;81 readonly message: string;
37 readonly requestedWorkspaceId: string;82 readonly requestedWorkspaceId: string;
38 readonly activeWorkspaceId: string;83 readonly activeWorkspaceId: string;
39}> {}84}> {}
40 85
41export class LocalConfiguredSourceNotFoundError extends Data.TaggedError("LocalConfiguredSourceNotFoundError")<{86export class LocalConfiguredSourceNotFoundError extends Data.TaggedError(
87 "LocalConfiguredSourceNotFoundError",
88)<{
42 readonly message: string;89 readonly message: string;
43 readonly sourceId: string;90 readonly sourceId: string;
44}> {}91}> {}
45 92
46export class LocalSourceArtifactMissingError extends Data.TaggedError("LocalSourceArtifactMissingError")<{93export class LocalSourceArtifactMissingError extends Data.TaggedError(
94 "LocalSourceArtifactMissingError",
95)<{
47 readonly message: string;96 readonly message: string;
48 readonly sourceId: string;97 readonly sourceId: string;
49}> {}98}> {}
50 99
51export class LocalUnsupportedSourceKindError extends Data.TaggedError("LocalUnsupportedSourceKindError")<{100export class LocalUnsupportedSourceKindError extends Data.TaggedError(
101 "LocalUnsupportedSourceKindError",
102)<{
52 readonly message: string;103 readonly message: string;
53 readonly kind: string;104 readonly kind: string;
54}> {}105}> {}
packages/control-plane/src/runtime/local-tools.ts+654−0
@@ -0,0 +1,654 @@
1import { createHash } from "node:crypto";
2import { existsSync } from "node:fs";
3import { dirname, extname, join, relative, resolve, sep } from "node:path";
4import { fileURLToPath, pathToFileURL } from "node:url";
5import { FileSystem } from "@effect/platform";
6import { NodeFileSystem } from "@effect/platform-node";
7import {
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";
20import * as Effect from "effect/Effect";
21import * as ts from "typescript";
22
23import type { ResolvedLocalWorkspaceContext } from "./local-config";
24import {
25 LocalFileSystemError,
26 LocalToolDefinitionError,
27 LocalToolImportError,
28 LocalToolPathConflictError,
29 LocalToolTranspileError,
30 unknownLocalErrorDetails,
31} from "./local-errors";
32
33const SUPPORTED_LOCAL_TOOL_EXTENSIONS = new Set([".ts", ".js", ".mjs"]);
34const LOCAL_TOOLS_DIRECTORY = "tools";
35const LOCAL_TOOLS_ARTIFACT_DIRECTORY = "local-tools";
36const LOCAL_TOOLS_SOURCE_KEY_PREFIX = "local.tool";
37const LOCAL_TOOL_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/;
38
39type Awaitable<T> = T | Promise<T>;
40
41type ParsedStandardSchema<TSchema extends StandardSchema<any, any>> =
42 TSchema extends StandardSchema<any, infer Parsed> ? Parsed : never;
43
44export 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
63export 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
73export 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
82export type LocalToolRuntime = {
83 tools: ToolMap;
84 catalog: ToolCatalog;
85 toolInvoker: ToolInvoker;
86 toolPaths: Set<string>;
87};
88
89const 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
99const 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
108const 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
116const localToolsDirectory = (context: ResolvedLocalWorkspaceContext): string =>
117 join(context.configDirectory, LOCAL_TOOLS_DIRECTORY);
118
119const localToolsArtifactDirectory = (
120 context: ResolvedLocalWorkspaceContext,
121): string => join(context.artifactsDirectory, LOCAL_TOOLS_ARTIFACT_DIRECTORY);
122
123const isSupportedLocalToolFile = (path: string): boolean =>
124 SUPPORTED_LOCAL_TOOL_EXTENSIONS.has(extname(path));
125
126const isIgnoredToolSegment = (segment: string): boolean =>
127 segment.startsWith(".") || segment.startsWith("_");
128
129const toArtifactRelativePath = (relativePath: string): string => {
130 const extension = extname(relativePath);
131 return `${relativePath.slice(0, -extension.length)}.js`;
132};
133
134const 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
172const 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
183const 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
232const 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
239const 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
270const 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
295const 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
313const 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
346const 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
365const EXECUTOR_NODE_MODULES_DIRECTORY = resolveExecutorNodeModulesDirectory();
366
367const 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
391const 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
487const 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
493const 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
499const 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
537const 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
584export 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 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { mkdtempSync } from "node:fs";
3import { createServer } from "node:http";2import { createServer } from "node:http";
4import { tmpdir } from "node:os";3import { dirname, join } from "node:path";
5import { join } from "node:path";
6 4
7import {5import {
6 FileSystem,
8 HttpApi,7 HttpApi,
9 HttpApiBuilder,8 HttpApiBuilder,
10 HttpApiEndpoint,9 HttpApiEndpoint,
@@ -12,6 +11,7 @@ import { · 1 unmodified line
12 HttpApiSchema,11 HttpApiSchema,
13 HttpServer,12 HttpServer,
14} from "@effect/platform";13} from "@effect/platform";
14import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
15import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";15import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
16import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";16import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
17import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";17import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -80,9 +80,7 @@ import { · 62 unmodified lines
80 loadLocalWorkspaceState,80 loadLocalWorkspaceState,
81 writeLocalWorkspaceState,81 writeLocalWorkspaceState,
82} from "./local-workspace-state";82} from "./local-workspace-state";
83import {83import { materializationFromMcpManifestEntries } from "./source-adapters/mcp";
84 materializationFromMcpManifestEntries,
85} from "./source-adapters/mcp";
86import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";84import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
87 85
88type CountedMcpServer = {86type CountedMcpServer = {
@@ -116,6 +114,15 @@ const normalizeSearchText = ( · 27 unmodified lines
116 .join(" ")114 .join(" ")
117 .toLowerCase();115 .toLowerCase();
118 116
117const 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
119const GraphqlUserType = new GraphQLObjectType({126const GraphqlUserType = new GraphQLObjectType({
120 name: "User",127 name: "User",
121 fields: {128 fields: {
@@ -941,98 +948,98 @@ const persistConnectedGithubOpenApiSource = (input: { · 819 unmodified lines
941 context: input.runtimeLocalWorkspace.context,948 context: input.runtimeLocalWorkspace.context,
942 sourceId: source.id,949 sourceId: source.id,
943 artifact: buildLocalSourceArtifact({950 artifact: buildLocalSourceArtifact({
944 source: {951 source: {
945 ...source,952 ...source,
946 sourceHash: manifest.sourceHash,953 sourceHash: manifest.sourceHash,
947 },954 },
948 materialization: {955 materialization: {
949 manifestJson: JSON.stringify(manifest),956 manifestJson: JSON.stringify(manifest),
950 manifestHash: manifest.sourceHash,957 manifestHash: manifest.sourceHash,
951 sourceHash: manifest.sourceHash,958 sourceHash: manifest.sourceHash,
952 documents: [959 documents: [
953 {960 {
954 id: `src_recipe_doc_${randomUUID()}`,961 id: `src_recipe_doc_${randomUUID()}`,
955 recipeRevisionId: "src_recipe_rev_materialization" as never,962 recipeRevisionId: "src_recipe_rev_materialization" as never,
956 documentKind: "openapi",963 documentKind: "openapi",
957 documentKey:964 documentKey:
958 typeof source.binding.specUrl === "string"965 typeof source.binding.specUrl === "string"
959 ? source.binding.specUrl966 ? source.binding.specUrl
960 : source.endpoint,967 : source.endpoint,
961 contentText: openApiDocumentText,968 contentText: openApiDocumentText,
962 contentHash: manifest.sourceHash,969 contentHash: manifest.sourceHash,
963 fetchedAt: now,970 fetchedAt: now,
964 createdAt: now,971 createdAt: now,
965 updatedAt: now,972 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 }),
1034 },973 },
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 };
1035 }),1040 }),
1041 },
1042 }),
1036 });1043 });
1037 yield* writeLocalWorkspaceState({1044 yield* writeLocalWorkspaceState({
1038 context: input.runtimeLocalWorkspace.context,1045 context: input.runtimeLocalWorkspace.context,
@@ -1054,62 +1061,104 @@ const persistConnectedGithubOpenApiSource = (input: { · 15 unmodified lines
1054 return;1061 return;
1055 }) as Effect.Effect<void, Error, never>;1062 }) as Effect.Effect<void, Error, never>;
1056 1063
1057const makeRuntimeLocalWorkspaceState = (workspaceId: WorkspaceId): Effect.Effect<RuntimeLocalWorkspaceState, Error, never> =>1064const makeRuntimeLocalWorkspaceState = (
1058 Effect.gen(function* () {1065 workspaceId: WorkspaceId,
1059 const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-workspace-env-"));1066): Effect.Effect<RuntimeLocalWorkspaceState, Error, Scope.Scope> =>
1060 const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });1067 provideNodeFileSystem(
1061 const loadedConfig = yield* loadLocalExecutorConfig(context);1068 Effect.gen(function* () {
1062 return {1069 const fs = yield* FileSystem.FileSystem;
1063 context,1070 const workspaceRoot = yield* fs.makeTempDirectoryScoped({
1064 installation: {1071 prefix: "executor-workspace-env-",
1065 workspaceId,1072 });
1066 accountId: AccountIdSchema.make("acc_local_test"),1073 const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
1067 },1074 const loadedConfig = yield* loadLocalExecutorConfig(context);
1068 loadedConfig,1075 return {
1069 } satisfies RuntimeLocalWorkspaceState;1076 context,
1070 });1077 installation: {
1071 1078 workspaceId,
1072const makeResolver = (1079 accountId: AccountIdSchema.make("acc_local_test"),
1073 persistence: SqlControlPlanePersistence,1080 },
1074 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,1081 loadedConfig,
1075) =>1082 } satisfies RuntimeLocalWorkspaceState;
1076 ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>1083 }),
1084 );
1085
1086const 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
1104const makeResolver =
1105 (
1106 persistence: SqlControlPlanePersistence,
1107 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1108 ) =>
1109 (
1110 input: Parameters<
1111 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
1112 >[0],
1113 ) =>
1077 createWorkspaceExecutionEnvironmentResolver({1114 createWorkspaceExecutionEnvironmentResolver({
1078 rows: persistence.rows,1115 rows: persistence.rows,
1079 sourceAuthService: {1116 sourceAuthService: {
1080 getLocalServerBaseUrl: () => null,1117 getLocalServerBaseUrl: () => null,
1081 storeSecretMaterial: () =>1118 storeSecretMaterial: () =>
1082 Effect.fail(new Error("not implemented in test")),1119 Effect.fail(new Error("not implemented in test")),
1083 getSourceById: () => Effect.fail(new Error("not implemented in test")),1120 getSourceById: () => Effect.fail(new Error("not implemented in test")),
1084 addExecutorSource: () =>1121 addExecutorSource: () =>
1085 Effect.fail(new Error("not implemented in test")),1122 Effect.fail(new Error("not implemented in test")),
1086 connectMcpSource: () => Effect.fail(new Error("not implemented in test")),1123 connectMcpSource: () =>
1087 startSourceOAuthSession: () =>1124 Effect.fail(new Error("not implemented in test")),
1088 Effect.fail(new Error("not implemented in test")),1125 startSourceOAuthSession: () =>
1089 completeSourceOAuthSession: () =>1126 Effect.fail(new Error("not implemented in test")),
1090 Effect.fail(new Error("not implemented in test")),1127 completeSourceOAuthSession: () =>
1091 completeSourceCredentialSetup: () =>1128 Effect.fail(new Error("not implemented in test")),
1092 Effect.fail(new Error("not implemented in test")),1129 completeSourceCredentialSetup: () =>
1093 } as RuntimeSourceAuthService,1130 Effect.fail(new Error("not implemented in test")),
1094 })(input).pipe(1131 } as RuntimeSourceAuthService,
1095 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),1132 })(input).pipe(
1096 ));1133 Effect.provideService(
1097 1134 RuntimeLocalWorkspaceService,
1098const makeResolverWithLiveSourceAuth = (1135 runtimeLocalWorkspace,
1099 persistence: SqlControlPlanePersistence,1136 ),
1100 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,1137 );
1101) =>1138
1102 ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>1139const makeResolverWithLiveSourceAuth =
1140 (
1141 persistence: SqlControlPlanePersistence,
1142 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1143 ) =>
1144 (
1145 input: Parameters<
1146 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
1147 >[0],
1148 ) =>
1103 createWorkspaceExecutionEnvironmentResolver({1149 createWorkspaceExecutionEnvironmentResolver({
1104 rows: persistence.rows,
1105 sourceAuthService: createRuntimeSourceAuthService({
1106 rows: persistence.rows,1150 rows: persistence.rows,
1107 liveExecutionManager: createLiveExecutionManager(),1151 sourceAuthService: createRuntimeSourceAuthService({
1108 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",1152 rows: persistence.rows,
1109 }),1153 liveExecutionManager: createLiveExecutionManager(),
1110 })(input).pipe(1154 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
1111 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),1155 }),
1112 ));1156 })(input).pipe(
1157 Effect.provideService(
1158 RuntimeLocalWorkspaceService,
1159 runtimeLocalWorkspace,
1160 ),
1161 );
1113 1162
1114describe("workspace-execution-environment", () => {1163describe("workspace-execution-environment", () => {
1115 it.scoped("discovers persisted tools without live MCP listing", () =>1164 it.scoped("discovers persisted tools without live MCP listing", () =>
@@ -1118,7 +1167,8 @@ describe("workspace-execution-environment", () => { · 2 unmodified lines
1118 const persistence = yield* makePersistence;1167 const persistence = yield* makePersistence;
1119 const workspaceId = WorkspaceIdSchema.make("ws_catalog");1168 const workspaceId = WorkspaceIdSchema.make("ws_catalog");
1120 const sourceId = SourceIdSchema.make("src_catalog");1169 const sourceId = SourceIdSchema.make("src_catalog");
1121 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1170 const runtimeLocalWorkspace =
1171 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1122 yield* persistConnectedEchoTool({1172 yield* persistConnectedEchoTool({
1123 runtimeLocalWorkspace,1173 runtimeLocalWorkspace,
1124 endpoint: server.endpoint,1174 endpoint: server.endpoint,
@@ -1132,7 +1182,10 @@ describe("workspace-execution-environment", () => { · 7 unmodified lines
1132 cause instanceof Error ? cause : new Error(String(cause)),1182 cause instanceof Error ? cause : new Error(String(cause)),
1133 }).pipe(Effect.orDie);1183 }).pipe(Effect.orDie);
1134 1184
1135 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);1185 const resolveEnvironment = makeResolver(
1186 persistence,
1187 runtimeLocalWorkspace,
1188 );
1136 const environment = yield* resolveEnvironment({1189 const environment = yield* resolveEnvironment({
1137 workspaceId,1190 workspaceId,
1138 accountId: AccountIdSchema.make("acc_catalog"),1191 accountId: AccountIdSchema.make("acc_catalog"),
@@ -1165,14 +1218,18 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines
1165 const persistence = yield* makePersistence;1218 const persistence = yield* makePersistence;
1166 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");1219 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");
1167 const sourceId = SourceIdSchema.make("src_github_discovery");1220 const sourceId = SourceIdSchema.make("src_github_discovery");
1168 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1221 const runtimeLocalWorkspace =
1222 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1169 yield* persistConnectedGithubOpenApiSource({1223 yield* persistConnectedGithubOpenApiSource({
1170 runtimeLocalWorkspace,1224 runtimeLocalWorkspace,
1171 workspaceId,1225 workspaceId,
1172 sourceId,1226 sourceId,
1173 });1227 });
1174 1228
1175 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);1229 const resolveEnvironment = makeResolver(
1230 persistence,
1231 runtimeLocalWorkspace,
1232 );
1176 const environment = yield* resolveEnvironment({1233 const environment = yield* resolveEnvironment({
1177 workspaceId,1234 workspaceId,
1178 accountId: AccountIdSchema.make("acc_github_discovery"),1235 accountId: AccountIdSchema.make("acc_github_discovery"),
@@ -1219,7 +1276,8 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
1219 const persistence = yield* makePersistence;1276 const persistence = yield* makePersistence;
1220 const workspaceId = WorkspaceIdSchema.make("ws_invoke");1277 const workspaceId = WorkspaceIdSchema.make("ws_invoke");
1221 const sourceId = SourceIdSchema.make("src_invoke");1278 const sourceId = SourceIdSchema.make("src_invoke");
1222 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1279 const runtimeLocalWorkspace =
1280 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1223 yield* persistConnectedEchoTool({1281 yield* persistConnectedEchoTool({
1224 runtimeLocalWorkspace,1282 runtimeLocalWorkspace,
1225 endpoint: server.endpoint,1283 endpoint: server.endpoint,
@@ -1227,7 +1285,10 @@ describe("workspace-execution-environment", () => { · 1 unmodified line
1227 sourceId,1285 sourceId,
1228 });1286 });
1229 1287
1230 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);1288 const resolveEnvironment = makeResolver(
1289 persistence,
1290 runtimeLocalWorkspace,
1291 );
1231 const environment = yield* resolveEnvironment({1292 const environment = yield* resolveEnvironment({
1232 workspaceId,1293 workspaceId,
1233 accountId: AccountIdSchema.make("acc_invoke"),1294 accountId: AccountIdSchema.make("acc_invoke"),
@@ -1249,6 +1310,126 @@ describe("workspace-execution-environment", () => { · 15 unmodified lines
1249 }),1310 }),
1250 );1311 );
1251 1312
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: `
1325import { Schema } from "effect"
1326
1327const inputSchema = Schema.standardSchemaV1(
1328 Schema.Struct({
1329 a: Schema.Number,
1330 b: Schema.Number,
1331 }),
1332)
1333
1334const outputSchema = Schema.standardSchemaV1(
1335 Schema.Struct({
1336 sum: Schema.Number,
1337 }),
1338)
1339
1340export 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: `
1355import { defineLocalTool } from "@executor/control-plane"
1356import * as z from "zod/v4"
1357
1358const inputSchema = z.object({
1359 value: z.string(),
1360})
1361
1362const outputSchema = z.object({
1363 value: z.string(),
1364})
1365
1366export 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
1252 it.scoped(1433 it.scoped(
1253 "adds an OpenAPI source through executor.sources.add with elicited credentials",1434 "adds an OpenAPI source through executor.sources.add with elicited credentials",
1254 () =>1435 () =>
@@ -1256,8 +1437,12 @@ describe("workspace-execution-environment", () => { · 1 unmodified line
1256 const specServer = yield* makeOpenApiSpecServer;1437 const specServer = yield* makeOpenApiSpecServer;
1257 const persistence = yield* makePersistence;1438 const persistence = yield* makePersistence;
1258 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");1439 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
1259 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1440 const runtimeLocalWorkspace =
1260 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);1441 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1442 const resolveEnvironment = makeResolverWithLiveSourceAuth(
1443 persistence,
1444 runtimeLocalWorkspace,
1445 );
1261 const now = Date.now();1446 const now = Date.now();
1262 const tokenSecretMaterialId = SecretMaterialIdSchema.make(1447 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1263 "sec_test_openapi_bearer",1448 "sec_test_openapi_bearer",
@@ -1338,7 +1523,9 @@ describe("workspace-execution-environment", () => { · 74 unmodified lines
1338 encodeURIComponent("exec_add_openapi:executor.sources.add:"),1523 encodeURIComponent("exec_add_openapi:executor.sources.add:"),
1339 );1524 );
1340 1525
1341 const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context);1526 const loadedConfig = yield* loadLocalExecutorConfig(
1527 runtimeLocalWorkspace.context,
1528 );
1342 expect(loadedConfig.config?.sources?.[added.id]).toMatchObject({1529 expect(loadedConfig.config?.sources?.[added.id]).toMatchObject({
1343 kind: "openapi",1530 kind: "openapi",
1344 connection: {1531 connection: {
@@ -1352,7 +1539,9 @@ describe("workspace-execution-environment", () => { · 7 unmodified lines
1352 expect(artifact?.documents[0]?.contentText).toContain(1539 expect(artifact?.documents[0]?.contentText).toContain(
1353 '"operationId":"repos.getRepo"',1540 '"operationId":"repos.getRepo"',
1354 );1541 );
1355 const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);1542 const workspaceState = yield* loadLocalWorkspaceState(
1543 runtimeLocalWorkspace.context,
1544 );
1356 expect(workspaceState.sources[added.id]?.status).toBe("connected");1545 expect(workspaceState.sources[added.id]?.status).toBe("connected");
1357 1546
1358 const freshEnvironment = yield* resolveEnvironment({1547 const freshEnvironment = yield* resolveEnvironment({
@@ -1399,8 +1588,12 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
1399 const graphqlServer = yield* makeGraphqlServer;1588 const graphqlServer = yield* makeGraphqlServer;
1400 const persistence = yield* makePersistence;1589 const persistence = yield* makePersistence;
1401 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");1590 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");
1402 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1591 const runtimeLocalWorkspace =
1403 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);1592 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1593 const resolveEnvironment = makeResolverWithLiveSourceAuth(
1594 persistence,
1595 runtimeLocalWorkspace,
1596 );
1404 const now = Date.now();1597 const now = Date.now();
1405 const tokenSecretMaterialId = SecretMaterialIdSchema.make(1598 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1406 "sec_test_graphql_bearer",1599 "sec_test_graphql_bearer",
@@ -1630,7 +1823,8 @@ describe("workspace-execution-environment", () => { · 223 unmodified lines
1630 const persistence = yield* makePersistence;1823 const persistence = yield* makePersistence;
1631 const workspaceId = WorkspaceIdSchema.make("ws_params_auth");1824 const workspaceId = WorkspaceIdSchema.make("ws_params_auth");
1632 const sourceId = SourceIdSchema.make("src_params_auth");1825 const sourceId = SourceIdSchema.make("src_params_auth");
1633 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1826 const runtimeLocalWorkspace =
1827 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1634 1828
1635 yield* persistConnectedGithubOpenApiSource({1829 yield* persistConnectedGithubOpenApiSource({
1636 runtimeLocalWorkspace,1830 runtimeLocalWorkspace,
@@ -1643,7 +1837,10 @@ describe("workspace-execution-environment", () => { · 6 unmodified lines
1643 },1837 },
1644 });1838 });
1645 1839
1646 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);1840 const resolveEnvironment = makeResolver(
1841 persistence,
1842 runtimeLocalWorkspace,
1843 );
1647 const environment = yield* resolveEnvironment({1844 const environment = yield* resolveEnvironment({
1648 workspaceId,1845 workspaceId,
1649 accountId: AccountIdSchema.make("acc_params_auth"),1846 accountId: AccountIdSchema.make("acc_params_auth"),
@@ -1683,7 +1880,8 @@ describe("workspace-execution-environment", () => { · 33 unmodified lines
1683 "ws_openapi_describe_bundle",1880 "ws_openapi_describe_bundle",
1684 );1881 );
1685 const sourceId = SourceIdSchema.make("src_openapi_describe_bundle");1882 const sourceId = SourceIdSchema.make("src_openapi_describe_bundle");
1686 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1883 const runtimeLocalWorkspace =
1884 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1687 1885
1688 yield* persistConnectedGithubOpenApiSource({1886 yield* persistConnectedGithubOpenApiSource({
1689 runtimeLocalWorkspace,1887 runtimeLocalWorkspace,
@@ -1691,7 +1889,10 @@ describe("workspace-execution-environment", () => { · 1 unmodified line
1691 sourceId,1889 sourceId,
1692 });1890 });
1693 1891
1694 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);1892 const resolveEnvironment = makeResolver(
1893 persistence,
1894 runtimeLocalWorkspace,
1895 );
1695 const environment = yield* resolveEnvironment({1896 const environment = yield* resolveEnvironment({
1696 workspaceId,1897 workspaceId,
1697 accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),1898 accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),
@@ -1739,8 +1940,12 @@ describe("workspace-execution-environment", () => { · 41 unmodified lines
1739 Effect.gen(function* () {1940 Effect.gen(function* () {
1740 const persistence = yield* makePersistence;1941 const persistence = yield* makePersistence;
1741 const workspaceId = WorkspaceIdSchema.make("ws_describe_add_source");1942 const workspaceId = WorkspaceIdSchema.make("ws_describe_add_source");
1742 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);1943 const runtimeLocalWorkspace =
1743 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);1944 yield* makeRuntimeLocalWorkspaceState(workspaceId);
1945 const resolveEnvironment = makeResolverWithLiveSourceAuth(
1946 persistence,
1947 runtimeLocalWorkspace,
1948 );
1744 1949
1745 const environment = yield* resolveEnvironment({1950 const environment = yield* resolveEnvironment({
1746 workspaceId,1951 workspaceId,
packages/control-plane/src/runtime/workspace-execution-environment.ts+279−185
@@ -2,8 +2,10 @@ import { · 1 unmodified line
2 createSystemToolMap,2 createSystemToolMap,
3 createToolCatalogFromTools,3 createToolCatalogFromTools,
4 makeToolInvokerFromTools,4 makeToolInvokerFromTools,
5 mergeToolMaps,
5 type SearchHit,6 type SearchHit,
6 type ToolCatalog,7 type ToolCatalog,
8 type ToolDescriptor,
7 type ToolInvoker,9 type ToolInvoker,
8 type ToolNamespace,10 type ToolNamespace,
9 type ToolPath,11 type ToolPath,
@@ -51,14 +53,13 @@ import { · 41 unmodified lines
51 evaluateInvocationPolicy,53 evaluateInvocationPolicy,
52 type InvocationDescriptor,54 type InvocationDescriptor,
53} from "./invocation-policy-engine";55} from "./invocation-policy-engine";
54import {56import { loadRuntimeLocalWorkspacePolicies } from "./policies-operations";
55 loadRuntimeLocalWorkspacePolicies,
56} from "./policies-operations";
57import {57import {
58 getRuntimeLocalWorkspaceOption,58 getRuntimeLocalWorkspaceOption,
59 RuntimeLocalWorkspaceService,59 RuntimeLocalWorkspaceService,
60 type RuntimeLocalWorkspaceState,60 type RuntimeLocalWorkspaceState,
61} from "./local-runtime-context";61} from "./local-runtime-context";
62import { loadLocalToolRuntime, type LocalToolRuntime } from "./local-tools";
62 63
63const asToolPath = (value: string): ToolPath => value as ToolPath;64const asToolPath = (value: string): ToolPath => value as ToolPath;
64 65
@@ -205,10 +206,19 @@ const loadWorkspaceSchemaBundle = (input: { · 140 unmodified lines
205 : null;206 : null;
206 }207 }
207 208
208 const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(input.id);209 const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(
210 input.id,
211 );
209 if (Option.isSome(bundle)) {212 if (Option.isSome(bundle)) {
210 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);213 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(
211 if (sourceRecords.some((sourceRecord) => sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId)) {214 input.workspaceId,
215 );
216 if (
217 sourceRecords.some(
218 (sourceRecord) =>
219 sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,
220 )
221 ) {
212 return {222 return {
213 id: bundle.value.id,223 id: bundle.value.id,
214 kind: bundle.value.bundleKind,224 kind: bundle.value.bundleKind,
@@ -386,7 +396,9 @@ const authorizePersistedToolInvocation = (input: { · 171 unmodified lines
386 >[0]["onElicitation"];396 >[0]["onElicitation"];
387}): Effect.Effect<void, Error, never> =>397}): Effect.Effect<void, Error, never> =>
388 Effect.gen(function* () {398 Effect.gen(function* () {
389 const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(399 const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(
400 input.workspaceId,
401 ).pipe(
390 Effect.mapError((cause) =>402 Effect.mapError((cause) =>
391 cause instanceof Error ? cause : new Error(String(cause)),403 cause instanceof Error ? cause : new Error(String(cause)),
392 ),404 ),
@@ -474,151 +486,205 @@ const provideRuntimeLocalWorkspace = <A, E, R>( · 81 unmodified lines
474 ),486 ),
475 );487 );
476 488
489const 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
507const 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
477const createWorkspaceToolCatalog = (input: {525const createWorkspaceToolCatalog = (input: {
478 workspaceId: Source["workspaceId"];526 workspaceId: Source["workspaceId"];
479 accountId: AccountId;527 accountId: AccountId;
480 rows: SqlControlPlaneRows;528 rows: SqlControlPlaneRows;
481 executorCatalog: ToolCatalog;529 authoredCatalog: ToolCatalog;
482 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;530 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
483}): ToolCatalog => ({531}): ToolCatalog => ({
484 listNamespaces: ({ limit }) =>532 listNamespaces: ({ limit }) =>
485 provideRuntimeLocalWorkspace(Effect.gen(function* () {533 provideRuntimeLocalWorkspace(
486 const [recipeTools, executor] = yield* Effect.all([534 Effect.gen(function* () {
487 loadWorkspaceRecipeTools({535 const [recipeTools, authored] = yield* Effect.all([
488 rows: input.rows,536 loadWorkspaceRecipeTools({
489 workspaceId: input.workspaceId,537 rows: input.rows,
490 accountId: input.accountId,538 workspaceId: input.workspaceId,
491 includeSchemas: false,539 accountId: input.accountId,
492 }),540 includeSchemas: false,
493 input.executorCatalog.listNamespaces({ limit }),541 }),
494 ]);542 input.authoredCatalog.listNamespaces({ limit }),
495 543 ]);
496 const merged = new Map<string, ToolNamespace>();544
497 for (const tool of recipeTools) {545 const merged = new Map<string, ToolNamespace>();
498 const existing = merged.get(tool.searchNamespace);546 for (const tool of recipeTools) {
499 merged.set(tool.searchNamespace, {547 const existing = merged.get(tool.searchNamespace);
500 namespace: tool.searchNamespace,548 merged.set(tool.searchNamespace, {
501 toolCount: (existing?.toolCount ?? 0) + 1,549 namespace: tool.searchNamespace,
502 });550 toolCount: (existing?.toolCount ?? 0) + 1,
503 }551 });
504 for (const namespace of executor) {552 }
505 const existing = merged.get(namespace.namespace);553 for (const namespace of authored) {
506 merged.set(namespace.namespace, {554 const existing = merged.get(namespace.namespace);
507 namespace: namespace.namespace,555 merged.set(namespace.namespace, {
508 displayName: namespace.displayName ?? existing?.displayName,556 namespace: namespace.namespace,
509 toolCount:557 displayName: namespace.displayName ?? existing?.displayName,
510 namespace.toolCount !== undefined ||558 toolCount:
511 existing?.toolCount === undefined559 namespace.toolCount !== undefined ||
512 ? namespace.toolCount560 existing?.toolCount === undefined
513 : existing.toolCount,561 ? namespace.toolCount
514 });562 : existing.toolCount,
515 }563 });
564 }
516 565
517 return [...merged.values()]566 return [...merged.values()]
518 .sort((left, right) => left.namespace.localeCompare(right.namespace))567 .sort((left, right) => left.namespace.localeCompare(right.namespace))
519 .slice(0, limit);568 .slice(0, limit);
520 }), input.runtimeLocalWorkspace),569 }),
570 input.runtimeLocalWorkspace,
571 ),
521 572
522 listTools: ({ namespace, query, limit, includeSchemas = false }) =>573 listTools: ({ namespace, query, limit, includeSchemas = false }) =>
523 provideRuntimeLocalWorkspace(Effect.gen(function* () {574 provideRuntimeLocalWorkspace(
524 const [recipeTools, executor] = yield* Effect.all([575 Effect.gen(function* () {
525 loadWorkspaceRecipeTools({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({
526 rows: input.rows,627 rows: input.rows,
527 workspaceId: input.workspaceId,628 workspaceId: input.workspaceId,
528 accountId: input.accountId,629 accountId: input.accountId,
630 path,
529 includeSchemas,631 includeSchemas,
530 }).pipe(632 });
531 Effect.map((tools) =>633 return recipeTool?.descriptor ?? null;
532 tools.filter((tool) => {634 }),
533 if (namespace && tool.searchNamespace !== namespace) {635 input.runtimeLocalWorkspace,
534 return false;636 ),
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 }
567 637
568 const recipeTool = yield* loadWorkspaceRecipeToolByPath({638 getSchemaBundle: ({ id }) =>
639 provideRuntimeLocalWorkspace(
640 loadWorkspaceSchemaBundle({
569 rows: input.rows,641 rows: input.rows,
570 workspaceId: input.workspaceId,642 workspaceId: input.workspaceId,
571 accountId: input.accountId,643 id: SourceRecipeSchemaBundleIdSchema.make(id),
572 path,644 }),
573 includeSchemas,645 input.runtimeLocalWorkspace,
574 });646 ),
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),
584 647
585 searchTools: ({ query, namespace, limit }) =>648 searchTools: ({ query, namespace, limit }) =>
586 provideRuntimeLocalWorkspace(Effect.gen(function* () {649 provideRuntimeLocalWorkspace(
587 const queryTokens = tokenize(query);650 Effect.gen(function* () {
588 const [recipeTools, executor] = yield* Effect.all([651 const queryTokens = tokenize(query);
589 loadWorkspaceRecipeTools({652 const [recipeTools, authored] = yield* Effect.all([
590 rows: input.rows,653 loadWorkspaceRecipeTools({
591 workspaceId: input.workspaceId,654 rows: input.rows,
592 accountId: input.accountId,655 workspaceId: input.workspaceId,
593 includeSchemas: false,656 accountId: input.accountId,
594 }).pipe(657 includeSchemas: false,
595 Effect.map((tools) =>658 }).pipe(
596 tools.filter(659 Effect.map((tools) =>
597 (tool) => !namespace || tool.searchNamespace === namespace,660 tools.filter(
661 (tool) => !namespace || tool.searchNamespace === namespace,
662 ),
598 ),663 ),
599 ),664 ),
600 ),665 input.authoredCatalog.searchTools({
601 input.executorCatalog.searchTools({666 query,
602 query,667 ...(namespace !== undefined ? { namespace } : {}),
603 ...(namespace !== undefined ? { namespace } : {}),668 limit,
604 limit,669 }),
605 }),670 ]);
606 ]);671
607 672 const recipeHits: SearchHit[] = recipeTools
608 const recipeHits: SearchHit[] = recipeTools673 .map((tool) => ({
609 .map((tool) => ({674 path: asToolPath(tool.path),
610 path: asToolPath(tool.path),675 score: scoreRecipeTool(queryTokens, tool),
611 score: scoreRecipeTool(queryTokens, tool),676 }))
612 }))677 .filter((hit) => hit.score > 0);
613 .filter((hit) => hit.score > 0);678
614 679 return dedupeSearchHits(authored, recipeHits)
615 return [...recipeHits, ...executor]680 .sort(
616 .sort(681 (left, right) =>
617 (left, right) =>682 right.score - left.score || left.path.localeCompare(right.path),
618 right.score - left.score || left.path.localeCompare(right.path),683 )
619 )684 .slice(0, limit);
620 .slice(0, limit);685 }),
621 }), input.runtimeLocalWorkspace),686 input.runtimeLocalWorkspace,
687 ),
622});688});
623 689
624const createWorkspaceToolInvoker = (input: {690const createWorkspaceToolInvoker = (input: {
@@ -628,6 +694,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines
628 resolveSecretMaterial: ResolveSecretMaterial;694 resolveSecretMaterial: ResolveSecretMaterial;
629 sourceAuthService: RuntimeSourceAuthService;695 sourceAuthService: RuntimeSourceAuthService;
630 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;696 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
697 localToolRuntime: LocalToolRuntime;
631 onElicitation?: Parameters<698 onElicitation?: Parameters<
632 typeof makeToolInvokerFromTools699 typeof makeToolInvokerFromTools
633 >[0]["onElicitation"];700 >[0]["onElicitation"];
@@ -641,25 +708,34 @@ const createWorkspaceToolInvoker = (input: { · 7 unmodified lines
641 sourceAuthService: input.sourceAuthService,708 sourceAuthService: input.sourceAuthService,
642 runtimeLocalWorkspace: input.runtimeLocalWorkspace,709 runtimeLocalWorkspace: input.runtimeLocalWorkspace,
643 });710 });
644 const executorCatalog = createToolCatalogFromTools({711 const authoredTools = mergeToolMaps([
645 tools: executorTools,712 executorTools,
713 input.localToolRuntime.tools,
714 ]);
715 const authoredCatalog = createToolCatalogFromTools({
716 tools: authoredTools,
646 });717 });
647 const catalog = createWorkspaceToolCatalog({718 const catalog = createWorkspaceToolCatalog({
648 workspaceId: input.workspaceId,719 workspaceId: input.workspaceId,
649 accountId: input.accountId,720 accountId: input.accountId,
650 rows: input.rows,721 rows: input.rows,
651 executorCatalog,722 authoredCatalog,
652 runtimeLocalWorkspace: input.runtimeLocalWorkspace,723 runtimeLocalWorkspace: input.runtimeLocalWorkspace,
653 });724 });
654 const systemTools = createSystemToolMap({ catalog });725 const systemTools = createSystemToolMap({ catalog });
655 const systemToolPaths = new Set(Object.keys(systemTools));726 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 }
657 const systemInvoker = makeToolInvokerFromTools({733 const systemInvoker = makeToolInvokerFromTools({
658 tools: systemTools,734 tools: systemTools,
659 onElicitation: input.onElicitation,735 onElicitation: input.onElicitation,
660 });736 });
661 const executorInvoker = makeToolInvokerFromTools({737 const authoredInvoker = makeToolInvokerFromTools({
662 tools: executorTools,738 tools: authoredTools,
663 onElicitation: input.onElicitation,739 onElicitation: input.onElicitation,
664 });740 });
665 741
@@ -668,54 +744,60 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines
668 args: unknown;744 args: unknown;
669 context?: Record<string, unknown>;745 context?: Record<string, unknown>;
670 }) =>746 }) =>
671 provideRuntimeLocalWorkspace(Effect.gen(function* () {747 provideRuntimeLocalWorkspace(
672 const recipeTool = yield* loadWorkspaceRecipeToolByPath({748 Effect.gen(function* () {
673 rows: input.rows,749 const recipeTool = yield* loadWorkspaceRecipeToolByPath({
674 workspaceId: input.workspaceId,750 rows: input.rows,
675 accountId: input.accountId,751 workspaceId: input.workspaceId,
676 path: invocation.path,752 accountId: input.accountId,
677 includeSchemas: false,753 path: invocation.path,
678 });754 includeSchemas: false,
679 if (!recipeTool) {755 });
680 return yield* Effect.fail(756 if (!recipeTool) {
681 new Error(`Unknown tool path: ${invocation.path}`),757 return yield* Effect.fail(
682 );758 new Error(`Unknown tool path: ${invocation.path}`),
683 }759 );
760 }
684 761
685 yield* authorizePersistedToolInvocation({762 yield* authorizePersistedToolInvocation({
686 rows: input.rows,763 rows: input.rows,
687 workspaceId: input.workspaceId,764 workspaceId: input.workspaceId,
688 accountId: input.accountId,765 accountId: input.accountId,
689 descriptor: toInvocationDescriptorFromRecipeTool({ tool: recipeTool }),766 descriptor: toInvocationDescriptorFromRecipeTool({
690 args: invocation.args,767 tool: recipeTool,
691 source: recipeTool.source,768 }),
692 context: invocation.context,769 args: invocation.args,
693 onElicitation: input.onElicitation,770 source: recipeTool.source,
694 });771 context: invocation.context,
772 onElicitation: input.onElicitation,
773 });
695 774
696 const auth = yield* resolveSourceAuthMaterial({775 const auth = yield* resolveSourceAuthMaterial({
697 rows: input.rows,776 rows: input.rows,
698 source: recipeTool.source,777 source: recipeTool.source,
699 actorAccountId: input.accountId,778 actorAccountId: input.accountId,
700 resolveSecretMaterial: input.resolveSecretMaterial,779 resolveSecretMaterial: input.resolveSecretMaterial,
701 context: toSecretResolutionContext(invocation.context),780 context: toSecretResolutionContext(invocation.context),
702 });781 });
703 const schemaBundle = recipeTool.schemaBundleId782 const schemaBundle = recipeTool.schemaBundleId
704 ? yield* loadWorkspaceSchemaBundle({783 ? yield* loadWorkspaceSchemaBundle({
705 rows: input.rows,784 rows: input.rows,
706 workspaceId: input.workspaceId,785 workspaceId: input.workspaceId,
707 id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),786 id: SourceRecipeSchemaBundleIdSchema.make(
708 })787 recipeTool.schemaBundleId,
709 : null;788 ),
710 const recipe = yield* loadSourceWithRecipe({789 })
711 rows: input.rows,790 : null;
712 workspaceId: input.workspaceId,791 const recipe = yield* loadSourceWithRecipe({
713 sourceId: recipeTool.source.id,792 rows: input.rows,
714 actorAccountId: input.accountId,793 workspaceId: input.workspaceId,
715 });794 sourceId: recipeTool.source.id,
795 actorAccountId: input.accountId,
796 });
716 797
717 return yield* getSourceAdapterForOperation(recipeTool.operation)798 return yield* getSourceAdapterForOperation(
718 .invokePersistedTool({799 recipeTool.operation,
800 ).invokePersistedTool({
719 rows: input.rows,801 rows: input.rows,
720 workspaceId: input.workspaceId,802 workspaceId: input.workspaceId,
721 accountId: input.accountId,803 accountId: input.accountId,
@@ -729,7 +811,9 @@ const createWorkspaceToolInvoker = (input: { · 7 unmodified lines
729 context: invocation.context,811 context: invocation.context,
730 onElicitation: input.onElicitation,812 onElicitation: input.onElicitation,
731 });813 });
732 }), input.runtimeLocalWorkspace);814 }),
815 input.runtimeLocalWorkspace,
816 );
733 817
734 return {818 return {
735 catalog,819 catalog,
@@ -738,8 +822,8 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines
738 provideRuntimeLocalWorkspace(822 provideRuntimeLocalWorkspace(
739 systemToolPaths.has(path)823 systemToolPaths.has(path)
740 ? systemInvoker.invoke({ path, args, context })824 ? systemInvoker.invoke({ path, args, context })
741 : executorToolPaths.has(path)825 : authoredToolPaths.has(path)
742 ? executorInvoker.invoke({ path, args, context })826 ? authoredInvoker.invoke({ path, args, context })
743 : invokePersistedTool({ path, args, context }),827 : invokePersistedTool({ path, args, context }),
744 input.runtimeLocalWorkspace,828 input.runtimeLocalWorkspace,
745 ),829 ),
@@ -761,6 +845,15 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 15 unmodified lines
761 return ({ workspaceId, accountId, onElicitation }) =>845 return ({ workspaceId, accountId, onElicitation }) =>
762 Effect.gen(function* () {846 Effect.gen(function* () {
763 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();847 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);
764 const { catalog, toolInvoker } = createWorkspaceToolInvoker({857 const { catalog, toolInvoker } = createWorkspaceToolInvoker({
765 workspaceId,858 workspaceId,
766 accountId,859 accountId,
@@ -768,6 +861,7 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 1 unmodified line
768 resolveSecretMaterial,861 resolveSecretMaterial,
769 sourceAuthService: input.sourceAuthService,862 sourceAuthService: input.sourceAuthService,
770 runtimeLocalWorkspace,863 runtimeLocalWorkspace,
864 localToolRuntime,
771 onElicitation,865 onElicitation,
772 });866 });
773 867