History

Enhance runtime configuration options in executor. Added support for specifying execution runtime in `.executor/executor.jsonc` with options: "quickjs", "ses", and "deno". Updated documentation and tests to reflect these changes. Implemented runtime selection logic in the control plane.

Rhys Sullivan committed 8 months ago

Commit 661ed291 parent e4deb6811 files · +260 −7Browse files

Unified · Split

11 files
  1. .changeset/bumpy-cloths-pay.md +5 −0
  2. ARCHITECTURE.md +2 −0
  3. README.md +11 −1
  4. apps/docs/developer/tool-catalog-and-execution.mdx +10 −0
  5. apps/executor/src/distribution/artifact.ts +12 −0
  6. apps/executor/src/distribution/distribution.test.ts +21 −4
  7. packages/control-plane/src/runtime/execution-runtime-config.test.ts +137 −0
  8. packages/control-plane/src/runtime/local-config.test.ts +17 −0
  9. packages/control-plane/src/runtime/local-config.ts +1 −0
  10. packages/control-plane/src/runtime/workspace-execution-environment.ts +36 −2
  11. packages/control-plane/src/schema/models/local-config.ts +8 −0
.changeset/bumpy-cloths-pay.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Support selecting runtime
ARCHITECTURE.md+2−0
@@ -117,6 +117,8 @@ and runs user-authored code against that environment. · 116 unmodified lines
117117
118118 The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane.
119119
120+The workspace can override that in `.executor/executor.jsonc` with `runtime: "quickjs" | "ses" | "deno"`.
121+
120122 ### Adapter packages
121123
122124 Several packages exist to turn external systems into callable tools:
README.md+11−1
@@ -228,11 +228,20 @@ At a high level, every execution follows the same loop: · 227 unmodified lines
228228
229229 1. `executor` resolves the current local installation and workspace.
230230 2. It builds a tool catalog from built-in tools plus all connected workspace sources.
231−3. It runs your TypeScript inside the QuickJS sandbox runtime by default.
231+3. It runs your TypeScript inside the configured sandbox runtime. QuickJS is the default, and `.executor/executor.jsonc` can set `"runtime": "quickjs" | "ses" | "deno"`.
232232 4. Tool calls are dispatched through `executor` rather than directly from your code.
233233 5. If a tool needs interaction, the run pauses and records a pending interaction.
234234 6. Once the interaction is resolved, the execution continues and eventually completes or fails.
235235
236+Example:
237+
238+```jsonc
239+{
240+ "runtime": "ses",
241+ "sources": {}
242+}
243+```
244+
236245 This gives you a stable surface for agent automation:
237246
238247 - the agent sees a coherent catalog
@@ -317,6 +326,7 @@ If you are exploring the repo, these are the directories that matter most: · 78 unmodified lines
317326 - `apps/web`: local React web UI
318327 - `packages/server`: local HTTP server that serves API, MCP, and UI
319328 - `packages/control-plane`: source management, secrets, persistence, execution, and inspection
329+- `packages/runtime-deno-subprocess`: optional Deno subprocess runtime for TypeScript execution
320330 - `packages/runtime-quickjs`: default QuickJS sandbox runtime for TypeScript execution
321331 - `packages/runtime-ses`: optional SES sandbox runtime for TypeScript execution
322332 - `packages/executor-mcp`: MCP bridge for `execute` and `resume`
apps/docs/developer/tool-catalog-and-execution.mdx+10−0
@@ -7,6 +7,16 @@ Executor turns connected integrations into a workspace tool catalog. · 6 unmodified lines
77
88 That catalog is the thing the runtime actually executes against.
99
10+The execution sandbox is selected from `.executor/executor.jsonc`:
11+
12+```jsonc
13+{
14+ "runtime": "quickjs"
15+}
16+```
17+
18+Supported values are `quickjs` (default), `ses`, and `deno`.
19+
1020 ## How tools are produced
1121
1222 At a high level:
apps/executor/src/distribution/artifact.ts+12−0
@@ -225,6 +225,18 @@ export const buildDistributionPackage = async ( · 224 unmodified lines
225225 join(repoRoot, "packages/runtime-deno-subprocess/src/deno-subprocess-worker.mjs"),
226226 join(binDir, "deno-subprocess-worker.mjs"),
227227 );
228+ await runCommand({
229+ command: "bun",
230+ args: [
231+ "build",
232+ "./packages/runtime-ses/src/sandbox-worker.mjs",
233+ "--target",
234+ "node",
235+ "--outfile",
236+ join(binDir, "sandbox-worker.mjs"),
237+ ],
238+ cwd: repoRoot,
239+ });
228240 await writeFile(join(packageDir, "package.json"), createPackageJson({
229241 packageName,
230242 packageVersion,
apps/executor/src/distribution/distribution.test.ts+21−4
@@ -10,12 +10,16 @@ describe("distribution flow", () => { · 9 unmodified lines
1010 const verifyInstallFlow = <R>(
1111 runCommand: (
1212 args: ReadonlyArray<string>,
13− ) => Effect.Effect<{ stdout: string }, Error, R>,
13+ options?: {
14+ readonly okExitCodes?: ReadonlyArray<number>;
15+ },
16+ ) => Effect.Effect<{ stdout: string; stderr: string }, Error, R>,
1417 ) =>
1518 Effect.gen(function* () {
1619 const harness = yield* DistributionHarness;
1720
1821 yield* harness.writeProjectConfig(`{
22+ "runtime": "ses",
1923 // local workspace config
2024 "sources": {},
2125 }
@@ -65,6 +69,17 @@ describe("distribution flow", () => { · 43 unmodified lines
6569 accountId: string;
6670 };
6771
72+ const sesCall = yield* runCommand(
73+ [
74+ "call",
75+ 'await fetch("https://example.com"); return 1;',
76+ "--base-url",
77+ harness.baseUrl,
78+ ],
79+ { okExitCodes: [1] },
80+ );
81+ expect(sesCall.stderr).toContain("fetch is disabled in SES executor");
82+
6883 yield* runCommand(["down", "--base-url", harness.baseUrl]);
6984 yield* runCommand(["up", "--base-url", harness.baseUrl]);
7085
@@ -84,11 +99,13 @@ describe("distribution flow", () => { · 13 unmodified lines
8499 });
85100
86101 it.live("boots a staged package artifact in a fresh home", () =>
87− verifyInstallFlow((args) => Effect.flatMap(DistributionHarness, (harness) => harness.run(args)))
102+ verifyInstallFlow((args, options) =>
103+ Effect.flatMap(DistributionHarness, (harness) => harness.run(args, options))
104+ )
88105 .pipe(Effect.provide(LocalDistributionHarnessLive)), 240_000);
89106
90107 it.live("boots an npm-installed package in a fresh home", () =>
91− verifyInstallFlow((args) =>
92− Effect.flatMap(DistributionHarness, (harness) => harness.runInstalled(args))
108+ verifyInstallFlow((args, options) =>
109+ Effect.flatMap(DistributionHarness, (harness) => harness.runInstalled(args, options))
93110 ).pipe(Effect.provide(LocalDistributionHarnessLive)), 240_000);
94111 });
packages/control-plane/src/runtime/execution-runtime-config.test.ts+137−0
@@ -0,0 +1,137 @@
1+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+import { describe, expect, it } from "@effect/vitest";
5+import * as Effect from "effect/Effect";
6+
7+import {
8+ createControlPlaneRuntime,
9+} from "./index";
10+import { withControlPlaneClient } from "./test-http-client";
11+
12+const writeProjectConfig = (
13+ workspaceRoot: string,
14+ config: Record<string, unknown>,
15+) => {
16+ const configDirectory = join(workspaceRoot, ".executor");
17+ mkdirSync(configDirectory, { recursive: true });
18+ writeFileSync(
19+ join(configDirectory, "executor.jsonc"),
20+ `${JSON.stringify(config, null, 2)}\n`,
21+ "utf8",
22+ );
23+};
24+
25+const makeRuntime = (config: Record<string, unknown>) => {
26+ const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-runtime-config-"));
27+ writeProjectConfig(workspaceRoot, config);
28+
29+ return Effect.acquireRelease(
30+ createControlPlaneRuntime({
31+ workspaceRoot,
32+ homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"),
33+ homeStateDirectory: join(workspaceRoot, ".executor-home-state"),
34+ }),
35+ (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
36+ );
37+};
38+
39+describe("execution runtime config", () => {
40+ it.scoped("defaults to QuickJS when no runtime is configured", () =>
41+ Effect.gen(function* () {
42+ const runtime = yield* makeRuntime({
43+ sources: {},
44+ });
45+ const installation = runtime.localInstallation;
46+
47+ const execution = yield* withControlPlaneClient(
48+ {
49+ runtime,
50+ accountId: installation.accountId,
51+ },
52+ (client) =>
53+ client.executions.create({
54+ path: {
55+ workspaceId: installation.workspaceId,
56+ },
57+ payload: {
58+ code: 'await fetch("https://example.com"); return 1;',
59+ },
60+ }),
61+ );
62+
63+ expect(execution.execution.status).toBe("failed");
64+ expect(execution.execution.errorText).toContain(
65+ "fetch is disabled in QuickJS executor",
66+ );
67+ }),
68+ 60_000,
69+ );
70+
71+ it.scoped("uses the SES runtime when configured in executor.jsonc", () =>
72+ Effect.gen(function* () {
73+ const runtime = yield* makeRuntime({
74+ runtime: "ses",
75+ sources: {},
76+ });
77+ const installation = runtime.localInstallation;
78+
79+ const execution = yield* withControlPlaneClient(
80+ {
81+ runtime,
82+ accountId: installation.accountId,
83+ },
84+ (client) =>
85+ client.executions.create({
86+ path: {
87+ workspaceId: installation.workspaceId,
88+ },
89+ payload: {
90+ code: 'await fetch("https://example.com"); return 1;',
91+ },
92+ }),
93+ );
94+
95+ expect(execution.execution.status).toBe("failed");
96+ expect(execution.execution.errorText).toContain(
97+ "fetch is disabled in SES executor",
98+ );
99+ }),
100+ 60_000,
101+ );
102+
103+ it.scoped("uses the Deno runtime when configured in executor.jsonc", () =>
104+ Effect.gen(function* () {
105+ const runtime = yield* makeRuntime({
106+ runtime: "deno",
107+ sources: {},
108+ });
109+ const installation = runtime.localInstallation;
110+
111+ const execution = yield* withControlPlaneClient(
112+ {
113+ runtime,
114+ accountId: installation.accountId,
115+ },
116+ (client) =>
117+ client.executions.create({
118+ path: {
119+ workspaceId: installation.workspaceId,
120+ },
121+ payload: {
122+ code: 'return typeof Deno !== "undefined";',
123+ },
124+ }),
125+ );
126+
127+ if (execution.execution.status === "completed") {
128+ expect(execution.execution.resultJson).toBe("true");
129+ return;
130+ }
131+
132+ expect(execution.execution.status).toBe("failed");
133+ expect(execution.execution.errorText).toContain("Install Deno or set DENO_BIN");
134+ }),
135+ 60_000,
136+ );
137+});
packages/control-plane/src/runtime/local-config.test.ts+17−0
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines
88
99 import {
1010 loadLocalExecutorConfig,
11+ mergeLocalExecutorConfigs,
1112 resolveDefaultHomeConfigCandidates,
1213 resolveDefaultHomeStateDirectory,
1314 resolveHomeConfigPath,
@@ -27,6 +28,7 @@ describe("local-config", () => { · 13 unmodified lines
2728 writeFile(
2829 join(configDirectory, "executor.jsonc"),
2930 `{
31+ "runtime": "ses",
3032 // local workspace config
3133 "sources": {
3234 "github": {
@@ -52,6 +54,7 @@ describe("local-config", () => { · 19 unmodified lines
5254 expect(loaded.config?.sources?.github?.connection.endpoint).toBe(
5355 "https://api.github.com",
5456 );
57+ expect(loaded.config?.runtime).toBe("ses");
5558 expect(context.homeStateDirectory).toContain("executor");
5659 }).pipe(Effect.provide(NodeFileSystem.layer)),
5760 );
@@ -146,4 +149,18 @@ describe("local-config", () => { · 88 unmodified lines
146149 expect(resolvedPath).toBe(join(legacyConfigDirectory, "executor.jsonc"));
147150 }).pipe(Effect.provide(NodeFileSystem.layer)),
148151 );
152+
153+ it("lets project config override the merged runtime", () => {
154+ const merged = mergeLocalExecutorConfigs(
155+ {
156+ runtime: "quickjs",
157+ sources: {},
158+ },
159+ {
160+ runtime: "deno",
161+ },
162+ );
163+
164+ expect(merged?.runtime).toBe("deno");
165+ });
149166 });
packages/control-plane/src/runtime/local-config.ts+1−0
@@ -258,6 +258,7 @@ export const mergeLocalExecutorConfigs = ( · 257 unmodified lines
258258 }
259259
260260 return decodeLocalExecutorConfig({
261+ runtime: extra?.runtime ?? base?.runtime,
261262 workspace: {
262263 ...(base?.workspace ?? {}),
263264 ...(extra?.workspace ?? {}),
packages/control-plane/src/runtime/workspace-execution-environment.ts+36−2
@@ -1,4 +1,5 @@
11 import {
2+ type CodeExecutor,
23 createSystemToolMap,
34 createToolCatalogFromEntries,
45 createToolCatalogFromTools,
@@ -9,8 +10,15 @@ import { · 4 unmodified lines
910 type ToolInvoker,
1011 type ToolPath,
1112 } from "@executor/codemode-core";
13+import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
1214 import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
13−import type { AccountId, Source } from "#schema";
15+import { makeSesExecutor } from "@executor/runtime-ses";
16+import type {
17+ AccountId,
18+ LocalExecutorConfig,
19+ LocalExecutorRuntime,
20+ Source,
21+} from "#schema";
1422 import * as Context from "effect/Context";
1523 import * as Either from "effect/Either";
1624 import * as Effect from "effect/Effect";
@@ -67,6 +75,26 @@ import { · 50 unmodified lines
6775
6876 const asToolPath = (value: string): ToolPath => value as ToolPath;
6977
78+const DEFAULT_EXECUTION_RUNTIME: LocalExecutorRuntime = "quickjs";
79+
80+export const resolveConfiguredExecutionRuntime = (
81+ config: LocalExecutorConfig | null | undefined,
82+): LocalExecutorRuntime => config?.runtime ?? DEFAULT_EXECUTION_RUNTIME;
83+
84+export const createCodeExecutorForRuntime = (
85+ runtime: LocalExecutorRuntime,
86+): CodeExecutor => {
87+ switch (runtime) {
88+ case "deno":
89+ return makeDenoSubprocessExecutor();
90+ case "ses":
91+ return makeSesExecutor();
92+ case "quickjs":
93+ default:
94+ return makeQuickJsExecutor();
95+ }
96+};
97+
7098 const tokenize = (value: string): string[] =>
7199 value
72100 .trim()
@@ -652,6 +680,10 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 579 unmodified lines
652680 ({ workspaceId, accountId, onElicitation }) =>
653681 Effect.gen(function* () {
654682 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
683+ const loadedConfig =
684+ runtimeLocalWorkspace === null
685+ ? null
686+ : yield* input.workspaceConfigStore.load(runtimeLocalWorkspace.context);
655687 const localToolRuntime =
656688 runtimeLocalWorkspace === null
657689 ? {
@@ -675,7 +707,9 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 17 unmodified lines
675707 onElicitation,
676708 });
677709
678− const executor = makeQuickJsExecutor();
710+ const executor = createCodeExecutorForRuntime(
711+ resolveConfiguredExecutionRuntime(loadedConfig?.config),
712+ );
679713
680714 return {
681715 executor,
packages/control-plane/src/schema/models/local-config.ts+8−0
@@ -2,6 +2,12 @@ import { Schema } from "effect"; · 1 unmodified line
22
33 import { SourceTransportSchema, StringMapSchema } from "./source";
44
5+export const LocalExecutorRuntimeSchema = Schema.Literal(
6+ "quickjs",
7+ "ses",
8+ "deno",
9+);
10+
511 export const LocalConfigSecretProviderSourceSchema = Schema.Literal(
612 "env",
713 "file",
@@ -156,6 +162,7 @@ export const LocalConfigWorkspaceSchema = Schema.Struct({ · 148 unmodified lines
156162 });
157163
158164 export const LocalExecutorConfigSchema = Schema.Struct({
165+ runtime: Schema.optional(LocalExecutorRuntimeSchema),
159166 workspace: Schema.optional(LocalConfigWorkspaceSchema),
160167 sources: Schema.optional(
161168 Schema.Record({
@@ -182,4 +189,5 @@ export type LocalConfigSecretInput = typeof LocalConfigSecretInputSchema.Type; · 20 unmodified lines
182189 export type LocalConfigSource = typeof LocalConfigSourceSchema.Type;
183190 export type LocalConfigPolicy = typeof LocalConfigPolicySchema.Type;
184191 export type LocalConfigSecrets = typeof LocalConfigSecretsSchema.Type;
192+export type LocalExecutorRuntime = typeof LocalExecutorRuntimeSchema.Type;
185193 export type LocalExecutorConfig = typeof LocalExecutorConfigSchema.Type;