Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cloud-app-bundler-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Run cloud custom tool bundling in a dynamically loaded Worker so dependency installation and bundling do not share the serving request isolate.
31 changes: 26 additions & 5 deletions apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
import {
makeDynamicWorkerAppToolExecutor,
makeNativeWorkerBundlerBackend,
makeDynamicWorkerBundlerBackend,
} from "@executor-js/plugin-apps/cloud";
import { appsHttpPlugin } from "@executor-js/plugin-apps/api";
import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
Expand Down Expand Up @@ -58,18 +58,30 @@ interface CloudPluginDeps {
readonly allowLocalNetwork?: boolean;
readonly workerLoader?: {
readonly get: (
name: string,
name: string | null,
factory: () => {
readonly compatibilityDate: string;
readonly compatibilityFlags?: string[];
readonly compatibilityFlags?: readonly string[];
readonly mainModule: string;
readonly modules: Readonly<Record<string, string>>;
readonly modules: Readonly<Record<string, string | { readonly wasm: ArrayBuffer }>>;
readonly globalOutbound?: null;
},
) => { readonly getEntrypoint: () => unknown };
readonly load?: (code: {
readonly compatibilityDate: string;
readonly compatibilityFlags?: readonly string[];
readonly mainModule: string;
readonly modules: Readonly<Record<string, string | { readonly wasm: ArrayBuffer }>>;
readonly globalOutbound?: null;
}) => { readonly getEntrypoint: () => unknown };
};
}

const base64ToArrayBuffer = (value: string): ArrayBuffer => {
const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
};

export default defineExecutorConfig({
plugins: ({
workosCredentials,
Expand All @@ -91,7 +103,16 @@ export default defineExecutorConfig({
...(workerLoader
? {
executor: makeDynamicWorkerAppToolExecutor({ loader: workerLoader }),
bundler: makeNativeWorkerBundlerBackend(),
bundler: makeDynamicWorkerBundlerBackend({
loader: workerLoader,
artifact: async () => {
const artifact = await import("virtual:executor/worker-bundler-artifact");
return {
source: artifact.source,
wasm: base64ToArrayBuffer(artifact.wasmBase64),
};
},
}),
}
: {}),
sourceKinds: ["git"],
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const steps = [
// Workspace packages whose exports app code (or this very vite config)
// resolves from dist under Node — vite's config loader externalizes bare
// imports, so they must be built before vite starts.
"turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react",
"turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps",
"vite build",
];

Expand Down
4 changes: 4 additions & 0 deletions apps/cloud/src/worker-bundler-artifact.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "virtual:executor/worker-bundler-artifact" {
export const source: string;
export const wasmBase64: string;
}
1 change: 1 addition & 0 deletions apps/cloud/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import executorVitePlugin from "@executor-js/vite-plugin";
import { workerBundlerArtifact } from "@executor-js/plugin-apps/vite";
import { unstable_readConfig } from "wrangler";

import { routes } from "./tsr.routes";
Expand Down Expand Up @@ -125,6 +126,7 @@ export default defineConfig(({ command, mode }) => {
},
plugins: [
devCrashGuard(),
workerBundlerArtifact(),
tailwindcss(),
executorVitePlugin(),
cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }),
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions e2e/fixtures/custom-tools-git/custom-tools-shas.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
48e7995f99423b64c8adf2aa07186571d9e1f66e
b8de904b34df6cad1dc29a49986f3703c0fe87f4
251a747551ac772f029d3f37e2e708e8fed20ee9
1f4b8f61f800b8164678f38c1c8c325e38baaf36
252ca58f9bea3e7dacaae04b828c57a497a6233c
f8b60f186b4a637bb5e5527da2a29da0f13b8683
Binary file modified e2e/fixtures/custom-tools-git/custom-tools-v1.pack
Binary file not shown.
Binary file modified e2e/fixtures/custom-tools-git/custom-tools-v2.pack
Binary file not shown.
Binary file modified e2e/fixtures/custom-tools-git/custom-tools-v3.pack
Binary file not shown.
158 changes: 158 additions & 0 deletions e2e/fixtures/custom-tools-git/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const fixtureDir = dirname(fileURLToPath(import.meta.url));

const run = (cwd: string, args: readonly string[], stdin?: string): string => {
const proc = Bun.spawnSync({
cmd: ["git", ...args],
cwd,
stdin: stdin ? new TextEncoder().encode(stdin) : undefined,
});
if (!proc.success) {
throw new Error(
`git ${args.join(" ")} failed\n${proc.stderr.toString()}\n${proc.stdout.toString()}`,
);
}
return proc.stdout.toString().trim();
};

const write = async (root: string, path: string, body: string) => {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), body);
};

const packageJson = JSON.stringify({ dependencies: { effect: "4.0.0-beta.59", zod: "4.3.6" } });
const executorJson = JSON.stringify({ description: "Custom tools e2e fixture" });

const echoTool = `import { z } from "zod";
import { defineTool, integration } from "executor:app";

export default defineTool({
description: "Echo a message through a projected app connection.",
integrations: { apps: integration("repo") },
input: z.object({ message: z.string() }),
output: {
type: "object",
properties: { message: { type: "string" }, version: { type: "string" } },
required: ["message", "version"],
},
annotations: { readOnly: true },
async handler(input) {
return { message: input.message, version: "v1" };
},
});
`;

const staticTool = `import { defineTool } from "executor:app";

export default defineTool({
description: "Return a static fixture marker.",
input: { type: "object", properties: {} },
output: {
type: "object",
properties: { ok: { type: "boolean" } },
required: ["ok"],
},
annotations: { readOnly: true },
async handler() {
return { ok: true };
},
});
`;

const effectTool = `import { Effect } from "effect";
import { defineTool } from "executor:app";

export default defineTool({
description: "Return a marker produced through Effect.",
input: { type: "object", properties: {} },
output: {
type: "object",
properties: { ok: { type: "boolean" }, dependency: { type: "string" } },
required: ["ok", "dependency"],
},
annotations: { readOnly: true },
async handler() {
return Effect.runSync(Effect.succeed({ ok: true, dependency: "effect" }));
},
});
`;

const extraTool = `import { defineTool } from "executor:app";

export default defineTool({
description: "Return a second static fixture marker.",
input: { type: "object", properties: {} },
output: {
type: "object",
properties: { added: { type: "boolean" } },
required: ["added"],
},
annotations: { readOnly: true },
async handler() {
return { added: true };
},
});
`;

const badCollectTool = `import { defineTool } from "executor:app";

const input = { type: "object", properties: {} };

export default {
"bad-collect": defineTool({
description: "Trigger a collect-stage fixture failure.",
input,
async handler() {
return { ok: true };
},
}),
};
`;

const commit = (root: string, message: string): string => {
run(root, ["add", "."]);
run(root, ["commit", "-q", "-m", message]);
return run(root, ["rev-parse", "HEAD"]);
};

const pack = async (root: string, sha: string, name: string) => {
const proc = Bun.spawnSync({
cmd: ["git", "pack-objects", "--stdout", "--revs"],
cwd: root,
stdin: new TextEncoder().encode(`${sha}\n`),
});
if (!proc.success) throw new Error(`git pack-objects failed: ${proc.stderr.toString()}`);
await writeFile(join(fixtureDir, name), proc.stdout);
};

const root = await mkdtemp(join(tmpdir(), "custom-tools-git-"));
try {
run(root, ["init", "-q"]);
run(root, ["config", "user.name", "Fixture Generator"]);
run(root, ["config", "user.email", "fixture@example.test"]);

await write(root, "executor.json", executorJson);
await write(root, "package.json", packageJson);
await write(root, "bun.lock", "");
await write(root, "tools/echo-tool.ts", echoTool);
await write(root, "tools/static-tool.ts", staticTool);
await write(root, "tools/effect-tool.ts", effectTool);
const sha1 = commit(root, "custom tools v1");

await write(root, "tools/extra-tool.ts", extraTool);
const sha2 = commit(root, "custom tools v2");

await write(root, "tools/bad-collect.ts", badCollectTool);
const sha3 = commit(root, "custom tools v3");

await writeFile(join(fixtureDir, "custom-tools-shas.txt"), `${sha1}\n${sha2}\n${sha3}\n`);
await pack(root, sha1, "custom-tools-v1.pack");
await pack(root, sha2, "custom-tools-v2.pack");
await pack(root, sha3, "custom-tools-v3.pack");
} finally {
await rm(root, { recursive: true, force: true });
}
26 changes: 20 additions & 6 deletions e2e/scenarios/custom-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,10 @@ const addSourceThroughConsole = (input: {
await page.locator('input[type="password"]').fill(FIXTURE_GIT_TOKEN);
await page.getByRole("button", { name: "Sync source" }).click();
await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 });
await page.getByLabel("Source").getByText("2 tools").waitFor({ timeout: 90_000 });
await page.getByLabel("Source").getByText("3 tools").waitFor({ timeout: 90_000 });
await page.getByRole("link", { name: "repo" }).waitFor({ timeout: 90_000 });
await page.getByRole("tab", { name: "Tools" }).click();
await page.getByRole("button", { name: /repo\s+2/ }).click();
await page.getByRole("button", { name: /repo\s+3/ }).click();
await page.getByRole("button", { name: "echo-tool", exact: true }).click();
await page.getByRole("tab", { name: "Run" }).click();
expect(await page.getByLabel("Connection").count()).toBe(0);
Expand Down Expand Up @@ -411,7 +411,11 @@ scenario(
const tools = yield* Effect.promise(() =>
request<readonly ToolRow[]>(target, identity, "/api/tools?integration=repo"),
);
expect(tools.body.map((tool) => tool.name).sort()).toEqual(["echo-tool", "static-tool"]);
expect(tools.body.map((tool) => tool.name).sort()).toEqual([
"echo-tool",
"effect-tool",
"static-tool",
]);
const echoTool = tools.body.find((tool) => tool.name === "echo-tool");
expect(echoTool).toBeDefined();

Expand Down Expand Up @@ -454,22 +458,31 @@ scenario(
expect(JSON.stringify(invoked.structured)).toContain("hello");
expect(JSON.stringify(invoked.structured)).toContain("v1");

const effectInvoked = yield* Effect.promise(() =>
execute(target, identity, `return await tools["repo.org.published.effect-tool"]({});`),
);
expect(effectInvoked.status, effectInvoked.text).toBe("completed");
expect(effectInvoked.isError, effectInvoked.text).toBe(false);
expect(effectInvoked.structured).toMatchObject({
result: { ok: true, data: { ok: true, dependency: "effect" } },
});

expect(git.packRequests(), "initial publish fetched one pack").toBe(1);
git.advance();
yield* syncSourceInConsole({
target,
browser,
identity,
expectedNotice: "Added: extra-tool",
expectedToolCount: "3 tools",
expectedToolCount: "4 tools",
});

yield* syncSourceInConsole({
target,
browser,
identity,
expectedNotice: "Already up to date.",
expectedToolCount: "3 tools",
expectedToolCount: "4 tools",
});

git.failCollect();
Expand All @@ -480,6 +493,7 @@ scenario(
);
expect(afterFailedCollect.body.map((tool) => tool.name).sort()).toEqual([
"echo-tool",
"effect-tool",
"extra-tool",
"static-tool",
]);
Expand All @@ -490,7 +504,7 @@ scenario(
browser,
identity,
expectedNotice: "Already up to date.",
expectedToolCount: "3 tools",
expectedToolCount: "4 tools",
});

yield* removeSourceThroughConsole({ target, browser, identity });
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"release:publish:packages:prepare": "bun run scripts/publish-packages.ts --prepare-only",
"release:smoke:packages": "bun run scripts/smoke-test-packed.ts",
"clean": "bun run scripts/clean.ts",
"prepare": "effect-language-service patch && effect-tsgo patch && bun run --cwd packages/core/vite-plugin build:bundle && bun run --cwd packages/react build"
"prepare": "effect-language-service patch && effect-tsgo patch && bun run --cwd packages/core/vite-plugin build:bundle && bun run --cwd packages/plugins/apps build:bundle && bun run --cwd packages/react build"
},
"dependencies": {},
"devDependencies": {
Expand Down
Loading
Loading