diff --git a/.changeset/durable-execution-receipts.md b/.changeset/durable-execution-receipts.md
new file mode 100644
index 0000000000..073a828410
--- /dev/null
+++ b/.changeset/durable-execution-receipts.md
@@ -0,0 +1,6 @@
+---
+"executor": patch
+"@executor-js/api": patch
+---
+
+Add idempotent execution submission, stable execution identifiers, and durable typed readback for paused and completed results.
diff --git a/.changeset/public-executor-api-client.md b/.changeset/public-executor-api-client.md
new file mode 100644
index 0000000000..9c14211b49
--- /dev/null
+++ b/.changeset/public-executor-api-client.md
@@ -0,0 +1,6 @@
+---
+"@executor-js/api": patch
+---
+
+Publish the runtime-neutral typed Executor HTTP client at `@executor-js/api/client` with a
+self-contained runtime schema bundle and clean public dependency boundary.
diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml
index 1966249d59..82829b49af 100644
--- a/.github/workflows/pkg-pr-new.yml
+++ b/.github/workflows/pkg-pr-new.yml
@@ -149,6 +149,7 @@ jobs:
'./apps/cli/dist/executor'
'./packages/core/storage-core'
'./packages/core/sdk'
+ './packages/core/api'
'./packages/core/config'
'./packages/core/execution'
'./packages/core/cli'
diff --git a/.gitignore b/.gitignore
index 4c3f791865..93c2d193f0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,6 +86,7 @@ executor.jsonc
.vscode/mcp.json
# reference repos (pulled via `bun run pull:references`)
+.repos/effect
.reference
.reference/
.worktrees/
diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts
index fc2a9bf391..8e3ca1d772 100644
--- a/apps/cli/src/main.ts
+++ b/apps/cli/src/main.ts
@@ -174,7 +174,6 @@ import {
buildInvokeToolCode,
buildListIntegrationsCode,
buildSearchToolsCode,
- extractExecutionId,
extractPausedInteraction,
extractExecutionResult,
inspectToolPath,
@@ -984,12 +983,13 @@ const executeCode = (input: {
const client = yield* makeApiClient(connection, input.target);
const response = yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: input.code,
},
});
if (response.status === "paused") {
- const executionId = extractExecutionId(response.structured);
+ const executionId = response.executionId;
return {
connection,
outcome: {
@@ -2087,14 +2087,24 @@ const resumeCommand = Command.make(
const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));
const client = yield* makeApiClient(connection, target);
+ const execution = yield* client.executions.get({ params: { executionId } });
+ if (execution.status === "completed") {
+ console.log(execution.text);
+ return;
+ }
const result = yield* client.executions.resume({
params: { executionId },
- payload: { action, content: contentObj },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ pauseSequence: execution.pauseSequence,
+ action,
+ content: contentObj,
+ },
});
if (result.status === "paused") {
console.log(result.text);
- const nextExecutionId = extractExecutionId(result.structured);
+ const nextExecutionId = result.executionId;
if (nextExecutionId) {
console.log("");
console.log("Approval required:");
diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts
index 738a358827..3e742288af 100644
--- a/apps/host-cloudflare/src/worker.e2e.node.test.ts
+++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts
@@ -112,13 +112,15 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
});
it("executes TypeScript via /api/executions (QuickJS on workerd)", async () => {
+ const request = { idempotencyKey: "worker-e2e", code: "export default 6 * 7" };
const res = await worker.fetch("/api/executions", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ code: "export default 6 * 7" }),
+ body: JSON.stringify(request),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
+ executionId: string;
status: string;
text: string;
isError: boolean;
@@ -126,6 +128,18 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
expect(body.status).toBe("completed");
expect(body.isError).toBe(false);
expect(body.text).toBe("42");
+
+ const replay = await worker.fetch("/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(request),
+ });
+ expect(replay.status).toBe(200);
+ expect(await replay.json()).toStrictEqual(body);
+
+ const readback = await worker.fetch(`/api/executions/${body.executionId}`);
+ expect(readback.status).toBe(200);
+ expect(await readback.json()).toStrictEqual(body);
}, 60_000);
it("adds a LARGE OpenAPI source — exercises the R2 blob seam (~1MB spec) + createMany batching (>100 tools)", async () => {
diff --git a/apps/host-selfhost/Dockerfile b/apps/host-selfhost/Dockerfile
index 9028188cdf..56940ca6aa 100644
--- a/apps/host-selfhost/Dockerfile
+++ b/apps/host-selfhost/Dockerfile
@@ -12,7 +12,11 @@
FROM oven/bun:1 AS prod-deps
WORKDIR /app
COPY . .
-RUN bun install --frozen-lockfile --production --ignore-scripts --filter @executor-js/host-selfhost \
+# The public API tarball exposes only the client, while its workspace server sources use
+# host-mcp and execution as build-time collaborators. Include API development dependencies
+# for this intermediate bundle stage; package-runtime copies only the runtime closure below.
+RUN bun install --frozen-lockfile --ignore-scripts \
+ --filter @executor-js/host-selfhost --filter @executor-js/api \
&& bun run apps/host-selfhost/scripts/package-runtime.ts
FROM oven/bun:1 AS build
diff --git a/apps/host-selfhost/src/boot.test.ts b/apps/host-selfhost/src/boot.test.ts
index c14707a043..fdbaa74322 100644
--- a/apps/host-selfhost/src/boot.test.ts
+++ b/apps/host-selfhost/src/boot.test.ts
@@ -84,16 +84,190 @@ test("POST /executions runs code in the QuickJS sandbox", async () => {
new Request("http://localhost/api/executions", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ code: "export default 6 * 7" }),
+ body: JSON.stringify({ idempotencyKey: "boot-test", code: "export default 6 * 7" }),
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
+ executionId: string;
status: string;
text: string;
isError: boolean;
+ requestHash: string;
+ resultHash: string;
+ completedAt: number;
};
expect(body.status).toBe("completed");
expect(body.text).toBe("42");
expect(body.isError).toBe(false);
+ expect(body.executionId).toMatch(/^exec_[a-f0-9]{64}$/);
+ expect(body.requestHash).toMatch(/^sha256:[a-f0-9]{64}$/);
+ expect(body.resultHash).toMatch(/^sha256:[a-f0-9]{64}$/);
+ expect(body.completedAt).toEqual(expect.any(Number));
+
+ const readback = await handler(
+ new Request(`http://localhost/api/executions/${body.executionId}`),
+ );
+ expect(readback.status).toBe(200);
+ expect(await readback.json()).toStrictEqual(body);
+
+ const replay = await handler(
+ new Request("http://localhost/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ idempotencyKey: "boot-test", code: "export default 6 * 7" }),
+ }),
+ );
+ expect(replay.status).toBe(200);
+ expect(await replay.json()).toStrictEqual(body);
+
+ const conflict = await handler(
+ new Request("http://localhost/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ idempotencyKey: "boot-test", code: "export default 7 * 7" }),
+ }),
+ );
+ expect(conflict.status).toBe(409);
+ expect(await conflict.json()).toMatchObject({
+ _tag: "ExecutionIdempotencyConflictError",
+ executionId: body.executionId,
+ });
+});
+
+test("paused executions replay and read back with the same stable id", async () => {
+ const artifactResponse = await handler(
+ new Request("http://localhost/api/artifacts", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ title: "Paused replay fixture",
+ code: "function App() { return
Paused replay fixture
; }",
+ }),
+ }),
+ );
+ expect(artifactResponse.status).toBe(200);
+ const artifact = (await artifactResponse.json()) as { id: string };
+
+ const request = {
+ idempotencyKey: "paused-replay-test",
+ code: `return await tools.executor.coreTools.policies.create(${JSON.stringify({
+ owner: "user",
+ pattern: "paused-replay-fixture.*",
+ action: "block",
+ })})`,
+ artifactId: artifact.id,
+ };
+ const first = await handler(
+ new Request("http://localhost/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(request),
+ }),
+ );
+ expect(first.status).toBe(200);
+ const receipt = (await first.json()) as {
+ executionId: string;
+ pauseSequence: number;
+ status: string;
+ };
+ expect(receipt.status).toBe("paused");
+ expect(receipt.pauseSequence).toBe(0);
+
+ const replay = await handler(
+ new Request("http://localhost/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(request),
+ }),
+ );
+ expect(replay.status).toBe(200);
+ expect(await replay.json()).toStrictEqual(receipt);
+
+ const readback = await handler(
+ new Request(`http://localhost/api/executions/${receipt.executionId}`),
+ );
+ expect(readback.status).toBe(200);
+ expect(await readback.json()).toStrictEqual(receipt);
+
+ const resumeRequest = {
+ idempotencyKey: "paused-replay-decline",
+ pauseSequence: receipt.pauseSequence,
+ action: "decline",
+ };
+ const declined = await handler(
+ new Request(`http://localhost/api/executions/${receipt.executionId}/resume`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(resumeRequest),
+ }),
+ );
+ expect(declined.status).toBe(200);
+ const completed = await declined.json();
+ expect(completed).toMatchObject({ executionId: receipt.executionId, status: "completed" });
+
+ const replayedDecline = await handler(
+ new Request(`http://localhost/api/executions/${receipt.executionId}/resume`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(resumeRequest),
+ }),
+ );
+ expect(replayedDecline.status).toBe(200);
+ expect(await replayedDecline.json()).toStrictEqual(completed);
+
+ const changedResume = await handler(
+ new Request(`http://localhost/api/executions/${receipt.executionId}/resume`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ ...resumeRequest, action: "cancel" }),
+ }),
+ );
+ expect(changedResume.status).toBe(409);
+ expect(await changedResume.json()).toMatchObject({
+ _tag: "ExecutionResumeConflictError",
+ executionId: receipt.executionId,
+ pauseSequence: receipt.pauseSequence,
+ });
+});
+
+test("GET /executions refuses malformed and missing ids", async () => {
+ const malformed = await handler(new Request("http://localhost/api/executions/not-an-id"));
+ expect(malformed.status).toBe(400);
+
+ const missing = await handler(new Request("http://localhost/api/executions/exec_missing"));
+ expect(missing.status).toBe(404);
+ expect(await missing.json()).toMatchObject({
+ _tag: "ExecutionNotFoundError",
+ executionId: "exec_missing",
+ });
+});
+
+test("POST /executions refuses malformed idempotency keys", async () => {
+ const missing = await handler(
+ new Request("http://localhost/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ code: "export default 1" }),
+ }),
+ );
+ expect(missing.status).toBe(400);
+
+ const empty = await handler(
+ new Request("http://localhost/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ idempotencyKey: "", code: "export default 1" }),
+ }),
+ );
+ expect(empty.status).toBe(400);
+
+ const tooLong = await handler(
+ new Request("http://localhost/api/executions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ idempotencyKey: "x".repeat(201), code: "export default 1" }),
+ }),
+ );
+ expect(tooLong.status).toBe(400);
});
diff --git a/apps/host-selfhost/src/multi-user.test.ts b/apps/host-selfhost/src/multi-user.test.ts
index 6d54393667..4871aa0758 100644
--- a/apps/host-selfhost/src/multi-user.test.ts
+++ b/apps/host-selfhost/src/multi-user.test.ts
@@ -121,7 +121,11 @@ const connectionAddresses = async (token: string): Promise => {
return body.map((c) => c.address);
};
-const runCode = async (token: string, code: string) => {
+const runCode = async (
+ token: string,
+ code: string,
+ idempotencyKey: string = crypto.randomUUID(),
+) => {
const res = await handler(
new Request(`${BASE}/api/executions`, {
method: "POST",
@@ -129,12 +133,19 @@ const runCode = async (token: string, code: string) => {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
- body: JSON.stringify({ code }),
+ body: JSON.stringify({ idempotencyKey, code }),
}),
);
return res;
};
+const getExecution = (token: string, executionId: string) =>
+ handler(
+ new Request(`${BASE}/api/executions/${encodeURIComponent(executionId)}`, {
+ headers: { authorization: `Bearer ${token}` },
+ }),
+ );
+
test("multiple accounts share one org but isolate per-user connections", async () => {
const alice = await signUp("alice@multi.test");
const bob = await signUp("bob@multi.test");
@@ -190,6 +201,31 @@ test("multiple accounts share one org but isolate per-user connections", async (
expect(bobConns.some((a) => a.includes(connectionName("alice-private")))).toBe(false);
});
+test("completed execution receipts are isolated by authenticated owner", async () => {
+ const alice = await signUp("receipt-alice@multi.test");
+ const bob = await signUp("receipt-bob@multi.test");
+ const completed = await runCode(alice, "export default 21 * 2", "shared-request-key");
+ expect(completed.status).toBe(200);
+ const receipt = (await completed.json()) as { readonly executionId: string };
+
+ const aliceRead = await getExecution(alice, receipt.executionId);
+ expect(aliceRead.status).toBe(200);
+ expect(await aliceRead.json()).toMatchObject({ executionId: receipt.executionId, text: "42" });
+
+ const bobRead = await getExecution(bob, receipt.executionId);
+ expect(bobRead.status).toBe(404);
+ expect(await bobRead.json()).toMatchObject({ _tag: "ExecutionNotFoundError" });
+
+ const bobCompleted = await runCode(bob, "export default 21 * 2", "shared-request-key");
+ expect(bobCompleted.status).toBe(200);
+ const bobReceipt = (await bobCompleted.json()) as {
+ readonly status: string;
+ readonly executionId: string;
+ };
+ expect(bobReceipt.status).toBe("completed");
+ expect(bobReceipt.executionId).not.toBe(receipt.executionId);
+});
+
test("each account can execute code in its own scoped sandbox", async () => {
const carol = await signUp("carol@multi.test");
const res = await runCode(carol, "export default 21 * 2");
diff --git a/apps/local/src/auth-tool-failures.test.ts b/apps/local/src/auth-tool-failures.test.ts
index a7599cb185..af568d8bcc 100644
--- a/apps/local/src/auth-tool-failures.test.ts
+++ b/apps/local/src/auth-tool-failures.test.ts
@@ -252,6 +252,7 @@ describe("local auth tool failures", () => {
const execution = yield* run((client) =>
client.executions.execute({
payload: {
+ idempotencyKey: "auth-failure-test",
code: [
`const result = await tools.${integration}.org.${connection}.default.ping({});`,
"return result;",
diff --git a/bun.lock b/bun.lock
index bdbd0a8ae4..4a41731664 100644
--- a/bun.lock
+++ b/bun.lock
@@ -481,18 +481,22 @@
"name": "@executor-js/api",
"version": "1.4.70",
"dependencies": {
- "@executor-js/execution": "workspace:*",
- "@executor-js/host-mcp": "workspace:*",
"@executor-js/sdk": "workspace:*",
- "effect": "catalog:",
},
"devDependencies": {
"@effect/vitest": "catalog:",
+ "@executor-js/execution": "workspace:*",
+ "@executor-js/host-mcp": "workspace:*",
"@types/node": "catalog:",
"bun-types": "catalog:",
+ "effect": "catalog:",
+ "tsup": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
},
+ "peerDependencies": {
+ "effect": "catalog:",
+ },
},
"packages/core/cli": {
"name": "@executor-js/cli",
diff --git a/e2e/cloud/auth-tool-failures.test.ts b/e2e/cloud/auth-tool-failures.test.ts
index e2159d76cf..b7898b6942 100644
--- a/e2e/cloud/auth-tool-failures.test.ts
+++ b/e2e/cloud/auth-tool-failures.test.ts
@@ -81,6 +81,7 @@ scenario(
const execution = yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: [`const result = await ${tool!.address}({});`, "return result;"].join("\n"),
},
});
diff --git a/e2e/cloud/connections-credentials.test.ts b/e2e/cloud/connections-credentials.test.ts
index 5f970a0a86..5f7b6856a4 100644
--- a/e2e/cloud/connections-credentials.test.ts
+++ b/e2e/cloud/connections-credentials.test.ts
@@ -214,6 +214,7 @@ scenario(
const execution = yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: [`const result = await ${address}({});`, "return result;"].join("\n"),
},
});
diff --git a/e2e/cloud/integrations-api.test.ts b/e2e/cloud/integrations-api.test.ts
index 785fe3d09b..cd3f6ef66d 100644
--- a/e2e/cloud/integrations-api.test.ts
+++ b/e2e/cloud/integrations-api.test.ts
@@ -176,6 +176,7 @@ scenario(
const execution = completed(
yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: [
`const result = await ${address}({ message: "hello", suffix: "world" });`,
"return result;",
@@ -285,6 +286,7 @@ scenario(
const execution = completed(
yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: [`const result = await ${address}({});`, "return result;"].join("\n"),
},
}),
@@ -342,6 +344,7 @@ scenario(
const execution = completed(
yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: [`const result = await ${address}({ name: "Ada" });`, "return result;"].join(
"\n",
),
diff --git a/e2e/cloud/telemetry-contract.test.ts b/e2e/cloud/telemetry-contract.test.ts
index 680c15b6d6..1a9a42e848 100644
--- a/e2e/cloud/telemetry-contract.test.ts
+++ b/e2e/cloud/telemetry-contract.test.ts
@@ -136,7 +136,10 @@ scenario(
// the exported span is the only place an operator can see it.
for (const address of [okAddress, failAddress]) {
const execution = yield* client.executions.execute({
- payload: { code: `return await ${address}({});` },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: `return await ${address}({});`,
+ },
});
expect(execution.status, `the ${address} execution completes`).toBe("completed");
}
diff --git a/e2e/scenarios/artifact-approval.test.ts b/e2e/scenarios/artifact-approval.test.ts
index ea1f52a34b..853c56e142 100644
--- a/e2e/scenarios/artifact-approval.test.ts
+++ b/e2e/scenarios/artifact-approval.test.ts
@@ -65,9 +65,6 @@ function App() {
}
`;
-const pausedExecutionId = (structured: unknown): string | undefined =>
- (structured as { readonly executionId?: string } | null)?.executionId;
-
scenario(
"Artifacts · a destructive action approved from an artifact runs after a human-scale delay",
{ timeout: 240_000 },
@@ -112,12 +109,12 @@ scenario(
// The artifact fires its mutation. The tool gates itself, so the shell
// gets a pause back and renders the approval modal.
const paused = yield* client.executions.execute({
- payload: { code, artifactId: artifact.id },
+ payload: { idempotencyKey: randomUUID(), code, artifactId: artifact.id },
});
expect(paused.status, "a destructive artifact action pauses for approval").toBe("paused");
if (paused.status !== "paused") return; // narrowing only
- const executionId = pausedExecutionId(paused.structured);
+ const executionId = paused.executionId;
expect(executionId, "the pause carries an execution id to approve").toBeTruthy();
if (!executionId) return; // narrowing only
@@ -135,7 +132,12 @@ scenario(
const approved = yield* client.executions.resume({
params: { executionId },
- payload: { action: "accept", content: {} },
+ payload: {
+ idempotencyKey: randomUUID(),
+ pauseSequence: paused.pauseSequence,
+ action: "accept",
+ content: {},
+ },
});
expect(approved.status, "approving the action runs it to completion").toBe("completed");
@@ -193,17 +195,21 @@ scenario(
yield* Effect.gen(function* () {
const paused = yield* client.executions.execute({
- payload: { code, artifactId: artifact.id },
+ payload: { idempotencyKey: randomUUID(), code, artifactId: artifact.id },
});
expect(paused.status, "the action pauses for approval").toBe("paused");
if (paused.status !== "paused") return; // narrowing only
- const executionId = pausedExecutionId(paused.structured);
+ const executionId = paused.executionId;
if (!executionId) return; // narrowing only
yield* client.executions.resume({
params: { executionId },
- payload: { action: "decline" },
+ payload: {
+ idempotencyKey: randomUUID(),
+ pauseSequence: paused.pauseSequence,
+ action: "decline",
+ },
});
const afterDecline = yield* client.policies.list();
@@ -215,7 +221,15 @@ scenario(
// A declined approval is spent: the same id cannot be re-approved into a
// run afterwards.
yield* client.executions
- .resume({ params: { executionId }, payload: { action: "accept", content: {} } })
+ .resume({
+ params: { executionId },
+ payload: {
+ idempotencyKey: randomUUID(),
+ pauseSequence: paused.pauseSequence,
+ action: "accept",
+ content: {},
+ },
+ })
.pipe(Effect.ignore);
const afterRetry = yield* client.policies.list();
diff --git a/e2e/scenarios/mcp-catalog-sync.test.ts b/e2e/scenarios/mcp-catalog-sync.test.ts
index bc9bc70231..d94b1bd22b 100644
--- a/e2e/scenarios/mcp-catalog-sync.test.ts
+++ b/e2e/scenarios/mcp-catalog-sync.test.ts
@@ -220,7 +220,11 @@ scenario(
// The catalog mutates DURING this call; the notification arrives on
// the same connection the call rides.
const executed = yield* client.executions.execute({
- payload: { code: invokeToolCode(slug, "main", "rename_greet", {}), autoApprove: true },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: invokeToolCode(slug, "main", "rename_greet", {}),
+ autoApprove: true,
+ },
});
expect(executed.status, "the mutating call completed").toBe("completed");
const outcome = JSON.parse(executed.text) as SandboxToolOutcome;
@@ -307,6 +311,7 @@ scenario(
// blindly.
const executed = yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: invokeToolCode(slug, "main", mutable.initialToolName, { name: "world" }),
autoApprove: true,
},
diff --git a/e2e/scenarios/mcp-session-state.test.ts b/e2e/scenarios/mcp-session-state.test.ts
index 21dfa0f084..5605b3e296 100644
--- a/e2e/scenarios/mcp-session-state.test.ts
+++ b/e2e/scenarios/mcp-session-state.test.ts
@@ -112,7 +112,11 @@ scenario(
yield* server.clearRequests;
const executed = yield* client.executions.execute({
- payload: { code: selectThenReadCode(slug), autoApprove: true },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: selectThenReadCode(slug),
+ autoApprove: true,
+ },
});
expect(executed.status, "the two-call execution completed").toBe("completed");
diff --git a/e2e/scenarios/microsoft-emulator.test.ts b/e2e/scenarios/microsoft-emulator.test.ts
index 4bd971dd06..943e5e6564 100644
--- a/e2e/scenarios/microsoft-emulator.test.ts
+++ b/e2e/scenarios/microsoft-emulator.test.ts
@@ -175,7 +175,10 @@ scenario(
).toContain("users.graphUserList");
const executed = yield* client.executions.execute({
- payload: { code: listUsersCode(integration) },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: listUsersCode(integration),
+ },
});
expect(executed.status, "the Graph tool execution completed").toBe("completed");
if (executed.status !== "completed") return;
diff --git a/e2e/scenarios/namespace-enumeration.test.ts b/e2e/scenarios/namespace-enumeration.test.ts
index b4c0684722..b06c7e66e3 100644
--- a/e2e/scenarios/namespace-enumeration.test.ts
+++ b/e2e/scenarios/namespace-enumeration.test.ts
@@ -91,6 +91,7 @@ scenario(
// surface an agent has.
const executed = yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: `
const integrations = await tools.executor.integrations.list({ limit: 50 });
const mine = integrations.items.find((item) => item.id === ${JSON.stringify(slug)});
diff --git a/e2e/scenarios/oauth-scope-insufficient.test.ts b/e2e/scenarios/oauth-scope-insufficient.test.ts
index dcfea135e4..18ac08e093 100644
--- a/e2e/scenarios/oauth-scope-insufficient.test.ts
+++ b/e2e/scenarios/oauth-scope-insufficient.test.ts
@@ -262,7 +262,11 @@ scenario(
const invoke = (address: string) =>
Effect.gen(function* () {
const executed = yield* client.executions.execute({
- payload: { code: invokeByAddressCode(address, {}), autoApprove: true },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: invokeByAddressCode(address, {}),
+ autoApprove: true,
+ },
});
expect(executed.status, "the sandbox execution completed").toBe("completed");
return JSON.parse(executed.text) as ToolEnvelope;
diff --git a/e2e/scenarios/openapi-unknown-args.test.ts b/e2e/scenarios/openapi-unknown-args.test.ts
index faca4b4604..57e627ad1a 100644
--- a/e2e/scenarios/openapi-unknown-args.test.ts
+++ b/e2e/scenarios/openapi-unknown-args.test.ts
@@ -123,6 +123,7 @@ scenario(
const executed = yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: invokeByAddressCode(address!, { itemId: "2", doesNotExist: "nope" }),
autoApprove: true,
},
diff --git a/e2e/scenarios/run-panel-auto-approve.test.ts b/e2e/scenarios/run-panel-auto-approve.test.ts
index f6a3e581e9..bcdde38983 100644
--- a/e2e/scenarios/run-panel-auto-approve.test.ts
+++ b/e2e/scenarios/run-panel-auto-approve.test.ts
@@ -80,7 +80,9 @@ scenario(
yield* Effect.gen(function* () {
// Baseline: without autoApprove the gated tool pauses (the panel's old
// dead-end), and the side effect must not have happened.
- const gated = yield* client.executions.execute({ payload: { code } });
+ const gated = yield* client.executions.execute({
+ payload: { idempotencyKey: crypto.randomUUID(), code },
+ });
expect(gated.status, "a gated tool pauses without autoApprove").toBe("paused");
const beforeApproval = yield* client.policies.list();
@@ -91,10 +93,17 @@ scenario(
// Release the paused fiber so it does not linger waiting on a response.
if (gated.status === "paused") {
- const executionId = (gated.structured as { readonly executionId?: string }).executionId;
+ const executionId = gated.executionId;
if (executionId) {
yield* client.executions
- .resume({ params: { executionId }, payload: { action: "cancel" } })
+ .resume({
+ params: { executionId },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ pauseSequence: gated.pauseSequence,
+ action: "cancel",
+ },
+ })
.pipe(Effect.ignore);
}
}
@@ -102,7 +111,7 @@ scenario(
// With autoApprove the operator IS the approver: the same call runs to
// completion and the side effect lands.
const approved = yield* client.executions.execute({
- payload: { code, autoApprove: true },
+ payload: { idempotencyKey: crypto.randomUUID(), code, autoApprove: true },
});
expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed");
if (approved.status !== "completed") return; // narrowing only
diff --git a/e2e/scenarios/shape-memory.test.ts b/e2e/scenarios/shape-memory.test.ts
index a1d2f5e123..20d93c9d81 100644
--- a/e2e/scenarios/shape-memory.test.ts
+++ b/e2e/scenarios/shape-memory.test.ts
@@ -122,7 +122,11 @@ scenario(
const describe = Effect.gen(function* () {
const executed = yield* client.executions.execute({
- payload: { code: describeCode(String(slug)), autoApprove: true },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: describeCode(String(slug)),
+ autoApprove: true,
+ },
});
expect(executed.status, executed.text).toBe("completed");
return JSON.parse(executed.text) as DescribeOutcome;
@@ -137,6 +141,7 @@ scenario(
// One real call against the live upstream teaches the shape.
const invoked = yield* client.executions.execute({
payload: {
+ idempotencyKey: crypto.randomUUID(),
code: `
const result = await tools.${slug}.org.main.issues.listIssues({});
return { ok: result.ok };
diff --git a/e2e/selfhost/mcp-enterprise-managed-auth.test.ts b/e2e/selfhost/mcp-enterprise-managed-auth.test.ts
index 114a9f40dd..8e9726ed94 100644
--- a/e2e/selfhost/mcp-enterprise-managed-auth.test.ts
+++ b/e2e/selfhost/mcp-enterprise-managed-auth.test.ts
@@ -300,7 +300,11 @@ scenario(
).toEqual([...SERVER_SCOPES].sort());
const executed = yield* client.executions.execute({
- payload: { code: callGetMeCode(String(integration), "main"), autoApprove: true },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: callGetMeCode(String(integration), "main"),
+ autoApprove: true,
+ },
});
expect(executed.status, "the tool call completed").toBe("completed");
const outcome = JSON.parse(executed.text) as SandboxToolOutcome;
diff --git a/e2e/selfhost/oauth-org-connection-cross-principal.test.ts b/e2e/selfhost/oauth-org-connection-cross-principal.test.ts
index 506c980ab5..a2639679bb 100644
--- a/e2e/selfhost/oauth-org-connection-cross-principal.test.ts
+++ b/e2e/selfhost/oauth-org-connection-cross-principal.test.ts
@@ -221,7 +221,10 @@ scenario(
const invoke = (client: typeof adminClient, who: string) =>
Effect.gen(function* () {
const execution = yield* client.executions.execute({
- payload: { code: invokeByAddressCode(address!) },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ code: invokeByAddressCode(address!),
+ },
});
expect(execution.status, `${who}: the execution completes`).toBe("completed");
if (execution.status !== "completed") return yield* Effect.die("not completed");
diff --git a/package.json b/package.json
index c284f45623..5f8dae39b2 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
"test": "turbo run test --filter=!@executor-js/e2e ${TURBO_TEST_CONCURRENCY:+--concurrency=$TURBO_TEST_CONCURRENCY}",
"test:e2e": "bun run --cwd e2e test",
"test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts",
- "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
+ "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/api' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build",
"typecheck": "turbo run typecheck",
"typecheck:slow": "turbo run typecheck:slow",
"ci": "bun run lint && bun run typecheck && bun run test",
@@ -68,7 +68,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 && bun run --cwd packages/hosts/mcp-apps-shell gen:smoke-harness"
+ "prepare": "./scripts/prepare-effect.sh && effect-language-service patch && effect-tsgo patch && bun run --cwd packages/core/vite-plugin build:bundle && bun run --cwd packages/react build && bun run --cwd packages/hosts/mcp-apps-shell gen:smoke-harness"
},
"dependencies": {},
"devDependencies": {
diff --git a/packages/core/api/package.json b/packages/core/api/package.json
index 5035661de6..dafeabf983 100644
--- a/packages/core/api/package.json
+++ b/packages/core/api/package.json
@@ -1,29 +1,57 @@
{
"name": "@executor-js/api",
"version": "1.4.70",
- "private": true,
+ "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/api",
+ "bugs": {
+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
+ "directory": "packages/core/api"
+ },
+ "files": [
+ "dist"
+ ],
"type": "module",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts"
},
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ "./client": {
+ "import": {
+ "types": "./dist/client.d.ts",
+ "default": "./dist/client.js"
+ }
+ }
+ }
+ },
"scripts": {
+ "build": "tsup",
"typecheck": "tsgo --noEmit",
"typecheck:slow": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
- "@executor-js/execution": "workspace:*",
- "@executor-js/host-mcp": "workspace:*",
- "@executor-js/sdk": "workspace:*",
- "effect": "catalog:"
+ "@executor-js/sdk": "workspace:*"
},
"devDependencies": {
"@effect/vitest": "catalog:",
+ "@executor-js/execution": "workspace:*",
+ "@executor-js/host-mcp": "workspace:*",
"@types/node": "catalog:",
"bun-types": "catalog:",
+ "effect": "catalog:",
+ "tsup": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
+ },
+ "peerDependencies": {
+ "effect": "catalog:"
}
}
diff --git a/packages/core/api/src/client.test.ts b/packages/core/api/src/client.test.ts
new file mode 100644
index 0000000000..39cefe31f0
--- /dev/null
+++ b/packages/core/api/src/client.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from "@effect/vitest";
+import { Effect, Layer, Ref, Schema } from "effect";
+import { readFile } from "node:fs/promises";
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
+
+import { makeExecutorApiClient } from "@executor-js/api/client";
+
+const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
+
+describe("makeExecutorApiClient", () => {
+ it.effect("calls the typed Executor API at the configured remote with explicit headers", () =>
+ Effect.gen(function* () {
+ const requests = yield* Ref.make>([]);
+ const httpClient = HttpClient.make((request) =>
+ Effect.gen(function* () {
+ yield* Ref.update(requests, (captured) => [...captured, request]);
+ return HttpClientResponse.fromWeb(
+ request,
+ new Response(encodeJson([]), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }),
+ );
+ const client = yield* makeExecutorApiClient({
+ baseUrl: "https://executor.example/api",
+ headers: { authorization: "Bearer service-token" },
+ transformClient: HttpClient.mapRequest((request) =>
+ HttpClientRequest.setHeader(request, "x-executor-org", "acme"),
+ ),
+ }).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient)(httpClient)));
+
+ const integrations = yield* client.integrations.list();
+ const [captured] = yield* Ref.get(requests);
+
+ expect(integrations).toEqual([]);
+ expect(captured).toMatchObject({
+ method: "GET",
+ url: "https://executor.example/api/integrations",
+ headers: {
+ authorization: "Bearer service-token",
+ "x-executor-org": "acme",
+ },
+ });
+ }),
+ );
+
+ it.effect("declares a public client boundary with no private runtime dependencies", () =>
+ Effect.gen(function* () {
+ const [source, changesetsSource] = yield* Effect.all([
+ Effect.promise(() => readFile(new URL("../package.json", import.meta.url), "utf8")),
+ Effect.promise(() =>
+ readFile(new URL("../../../../.changeset/config.json", import.meta.url), "utf8"),
+ ),
+ ]);
+ const manifest = decodeJson(source) as {
+ readonly private?: boolean;
+ readonly license?: string;
+ readonly files?: ReadonlyArray;
+ readonly scripts?: Readonly>;
+ readonly dependencies?: Readonly>;
+ readonly devDependencies?: Readonly>;
+ readonly optionalDependencies?: Readonly>;
+ readonly peerDependencies?: Readonly>;
+ readonly publishConfig?: {
+ readonly access?: string;
+ readonly exports?: Readonly>;
+ };
+ };
+ const changesets = decodeJson(changesetsSource) as {
+ readonly ignore?: ReadonlyArray;
+ };
+
+ expect(manifest).toMatchObject({
+ license: "MIT",
+ files: ["dist"],
+ scripts: { build: "tsup" },
+ peerDependencies: { effect: "catalog:" },
+ publishConfig: {
+ access: "public",
+ exports: {
+ "./client": {
+ import: {
+ types: "./dist/client.d.ts",
+ default: "./dist/client.js",
+ },
+ },
+ },
+ },
+ });
+ expect(manifest.dependencies).toEqual({ "@executor-js/sdk": "workspace:*" });
+ expect(manifest.devDependencies).toMatchObject({
+ "@executor-js/execution": "workspace:*",
+ "@executor-js/host-mcp": "workspace:*",
+ });
+ const publishableDependencies = [
+ ...Object.keys(manifest.dependencies ?? {}),
+ ...Object.keys(manifest.optionalDependencies ?? {}),
+ ...Object.keys(manifest.peerDependencies ?? {}),
+ ];
+ expect(publishableDependencies.filter((name) => changesets.ignore?.includes(name))).toEqual(
+ [],
+ );
+ expect(manifest.private).not.toBe(true);
+ }),
+ );
+});
diff --git a/packages/core/api/src/client.ts b/packages/core/api/src/client.ts
index a46ec456fe..3976c56a73 100644
--- a/packages/core/api/src/client.ts
+++ b/packages/core/api/src/client.ts
@@ -1,4 +1,11 @@
-export { ExecutorApi, CoreExecutorApi } from "./api";
+import type { Effect } from "effect";
+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
+import type { Headers } from "effect/unstable/http";
+import { HttpApiClient } from "effect/unstable/httpapi";
+
+import { CoreExecutorApi, ExecutorApi } from "./api";
+
+export { CoreExecutorApi, ExecutorApi };
export { ToolsApi } from "./tools/api";
export { IntegrationsApi } from "./integrations/api";
export { ConnectionsApi } from "./connections/api";
@@ -14,6 +21,46 @@ export {
AccountNoOrganization,
AccountUnauthorized,
} from "./account/api";
+
+/** The exact typed client generated from Executor's first-party HTTP API. */
+export type ExecutorApiClient = HttpApiClient.ForApi;
+
+type HttpApiClientOptions = NonNullable[1]>;
+
+export interface ExecutorApiClientOptions {
+ readonly baseUrl: string | URL;
+ /** Static request headers, including Authorization when the credential is fixed. */
+ readonly headers?: Headers.Input;
+ /** Dynamic auth or transport customization, applied after static headers. */
+ readonly transformClient?: HttpApiClientOptions["transformClient"];
+ readonly transformResponse?: HttpApiClientOptions["transformResponse"];
+}
+
+/**
+ * Build the first-party typed remote client. The caller supplies the concrete
+ * HttpClient layer, keeping this entry point server-safe and runtime-neutral.
+ */
+export const makeExecutorApiClient = (
+ options: ExecutorApiClientOptions,
+): Effect.Effect => {
+ const headers = options.headers;
+ return HttpApiClient.make(ExecutorApi, {
+ baseUrl: options.baseUrl,
+ transformClient:
+ headers === undefined && options.transformClient === undefined
+ ? undefined
+ : (client) => {
+ const withHeaders =
+ headers === undefined
+ ? client
+ : HttpClient.mapRequest(client, (request) =>
+ HttpClientRequest.setHeaders(request, headers),
+ );
+ return options.transformClient?.(withHeaders) ?? withHeaders;
+ },
+ transformResponse: options.transformResponse,
+ });
+};
export {
AdminUsersApi,
AdminUsersHttpApi,
diff --git a/packages/core/api/src/executions/api.ts b/packages/core/api/src/executions/api.ts
index a7d22d9690..0d499b2330 100644
--- a/packages/core/api/src/executions/api.ts
+++ b/packages/core/api/src/executions/api.ts
@@ -1,6 +1,12 @@
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
import { Schema } from "effect";
+import {
+ ExecutionId,
+ ExecutionIdempotencyKey,
+ ExecutionPauseSequence,
+ ExecutionReceipt,
+} from "@executor-js/sdk";
import { InternalError } from "@executor-js/sdk/shared";
// ---------------------------------------------------------------------------
@@ -8,6 +14,7 @@ import { InternalError } from "@executor-js/sdk/shared";
// ---------------------------------------------------------------------------
const ExecuteRequest = Schema.Struct({
+ idempotencyKey: ExecutionIdempotencyKey,
code: Schema.String,
// When true the caller is the human approver: approval-gated tools run to
// completion instead of pausing. Set by the operator-facing Run/Test panel,
@@ -26,37 +33,31 @@ const ExecuteRequest = Schema.Struct({
artifactId: Schema.optional(Schema.String),
});
-const CompletedResult = Schema.Struct({
- status: Schema.Literal("completed"),
- text: Schema.String,
- structured: Schema.Unknown,
- isError: Schema.Boolean,
-});
-
-const PausedResult = Schema.Struct({
- status: Schema.Literal("paused"),
- text: Schema.String,
- structured: Schema.Unknown,
-});
-
-const ExecuteResponse = Schema.Union([CompletedResult, PausedResult]);
-
const ResumeRequest = Schema.Struct({
+ idempotencyKey: ExecutionIdempotencyKey,
+ pauseSequence: ExecutionPauseSequence,
action: Schema.Literals(["accept", "decline", "cancel"]),
- content: Schema.optional(Schema.Unknown),
-});
-
-const ResumeResponse = Schema.Union([CompletedResult, PausedResult]);
-
-const PausedExecutionInfo = Schema.Struct({
- text: Schema.String,
- structured: Schema.Unknown,
+ content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
});
const ExecutionNotFoundError = Schema.TaggedStruct("ExecutionNotFoundError", {
- executionId: Schema.String,
+ executionId: ExecutionId,
}).annotate({ httpApiStatus: 404 });
+const ExecutionInProgressError = Schema.TaggedStruct("ExecutionInProgressError", {
+ executionId: ExecutionId,
+}).annotate({ httpApiStatus: 409 });
+
+const ExecutionIdempotencyConflictError = Schema.TaggedStruct("ExecutionIdempotencyConflictError", {
+ executionId: ExecutionId,
+ idempotencyKey: ExecutionIdempotencyKey,
+}).annotate({ httpApiStatus: 409 });
+
+const ExecutionResumeConflictError = Schema.TaggedStruct("ExecutionResumeConflictError", {
+ executionId: ExecutionId,
+ pauseSequence: ExecutionPauseSequence,
+}).annotate({ httpApiStatus: 409 });
+
/**
* The approval window closed before the human answered.
*
@@ -67,7 +68,7 @@ const ExecutionNotFoundError = Schema.TaggedStruct("ExecutionNotFoundError", {
* expired-approval state rather than an error.
*/
const ApprovalExpiredError = Schema.TaggedStruct("ApprovalExpiredError", {
- executionId: Schema.String,
+ executionId: ExecutionId,
})
.annotate({ httpApiStatus: 410 })
.annotate({
@@ -99,7 +100,7 @@ const ArtifactActionError = Schema.TaggedStruct("ArtifactActionError", {
// Params
// ---------------------------------------------------------------------------
-const ExecutionParams = { executionId: Schema.String };
+const ExecutionParams = { executionId: ExecutionId };
// ---------------------------------------------------------------------------
// Group
@@ -107,24 +108,35 @@ const ExecutionParams = { executionId: Schema.String };
export const ExecutionsApi = HttpApiGroup.make("executions")
.add(
- HttpApiEndpoint.get("getPaused", "/executions/:executionId", {
+ HttpApiEndpoint.get("get", "/executions/:executionId", {
params: ExecutionParams,
- success: PausedExecutionInfo,
- error: [InternalError, ExecutionNotFoundError],
+ success: ExecutionReceipt,
+ error: [InternalError, ExecutionNotFoundError, ExecutionInProgressError],
}),
)
.add(
HttpApiEndpoint.post("execute", "/executions", {
payload: ExecuteRequest,
- success: ExecuteResponse,
- error: [InternalError, ArtifactActionError],
+ success: ExecutionReceipt,
+ error: [
+ InternalError,
+ ArtifactActionError,
+ ExecutionInProgressError,
+ ExecutionIdempotencyConflictError,
+ ],
}),
)
.add(
HttpApiEndpoint.post("resume", "/executions/:executionId/resume", {
params: ExecutionParams,
payload: ResumeRequest,
- success: ResumeResponse,
- error: [InternalError, ExecutionNotFoundError, ApprovalExpiredError],
+ success: ExecutionReceipt,
+ error: [
+ InternalError,
+ ExecutionNotFoundError,
+ ApprovalExpiredError,
+ ExecutionInProgressError,
+ ExecutionResumeConflictError,
+ ],
}),
);
diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts
index 67f77de650..2320e9f4fd 100644
--- a/packages/core/api/src/handlers/executions.ts
+++ b/packages/core/api/src/handlers/executions.ts
@@ -1,30 +1,50 @@
+import { Clock, Effect, Schema } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
-import { Effect } from "effect";
-import { Schema } from "effect";
-import { ExecutorApi } from "../api";
+import { capture, captureEngineError } from "@executor-js/api";
import { formatExecuteResult, formatPausedExecution } from "@executor-js/execution";
import { resolveArtifactAction } from "@executor-js/host-mcp/artifact-action";
import { TOOL_CALL_CONTRACT_MESSAGE } from "@executor-js/host-mcp/tool-call-code";
-import { PENDING_APPROVAL_TTL_MS } from "@executor-js/sdk";
+import {
+ CompletedExecutionReceipt,
+ PENDING_APPROVAL_TTL_MS,
+ PausedExecutionReceipt,
+ RunningResumeReservation,
+ SettledResumeReservation,
+ sha256Hex,
+ StorageError,
+ type ExecutionIdempotencyKey,
+ type ExecutionReceipt,
+ type PausedExecutionReceipt as PausedReceipt,
+ type RunningExecution,
+} from "@executor-js/sdk";
+
+import { ExecutorApi } from "../api";
import { ExecutionEngineService, ExecutorService } from "../services";
-import { capture, captureEngineError } from "@executor-js/api";
class ExecutionNotFoundError extends Schema.TaggedErrorClass()(
"ExecutionNotFoundError",
- {
- executionId: Schema.String,
- },
+ { executionId: Schema.String },
+) {}
+
+class ExecutionInProgressError extends Schema.TaggedErrorClass()(
+ "ExecutionInProgressError",
+ { executionId: Schema.String },
+ { httpApiStatus: 409 },
+) {}
+
+class ExecutionIdempotencyConflictError extends Schema.TaggedErrorClass()(
+ "ExecutionIdempotencyConflictError",
+ { executionId: Schema.String, idempotencyKey: Schema.String },
+ { httpApiStatus: 409 },
+) {}
+
+class ExecutionResumeConflictError extends Schema.TaggedErrorClass()(
+ "ExecutionResumeConflictError",
+ { executionId: Schema.String, pauseSequence: Schema.Number },
+ { httpApiStatus: 409 },
) {}
-/**
- * An artifact-originated execution that could not be resolved into a call.
- *
- * Carries the same vocabulary the MCP host's `execute-action` returns
- * (`invalid_action_code`, `artifact_unavailable`, `binding_unresolved`) so the
- * shell sees one contract whichever transport it reached the server through,
- * and the binding UI that ships with sharing can key off `role`/`integration`.
- */
class ArtifactActionError extends Schema.TaggedErrorClass()(
"ArtifactActionError",
{
@@ -40,19 +60,9 @@ class ArtifactActionError extends Schema.TaggedErrorClass()
}
}
-/**
- * An approval that expired before the human answered.
- *
- * Distinct from `ExecutionNotFoundError` (an id that was never ours) so the
- * shell can tell the user their approval window closed and the action can simply
- * be triggered again — nothing ran. 410 Gone, because the resource existed and
- * deliberately no longer does.
- */
class ApprovalExpiredError extends Schema.TaggedErrorClass()(
"ApprovalExpiredError",
- {
- executionId: Schema.String,
- },
+ { executionId: Schema.String },
{ httpApiStatus: 410 },
) {
override get message(): string {
@@ -60,13 +70,21 @@ class ApprovalExpiredError extends Schema.TaggedErrorClass
}
}
-/**
- * Parse and bind one artifact-originated call, or fail with something the shell
- * can render inside the component that made it.
- *
- * The artifact is read through the request's own scoped executor, so an id that
- * isn't this caller's simply doesn't resolve.
- */
+const jsonEncode = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
+
+const digest = (value: unknown) =>
+ jsonEncode(value).pipe(
+ Effect.mapError(
+ (cause) =>
+ new StorageError({
+ message: "Execution receipt value is not JSON serializable",
+ cause,
+ }),
+ ),
+ Effect.flatMap(sha256Hex),
+ Effect.map((hash) => `sha256:${hash}`),
+ );
+
const resolveArtifactCode = (
code: string,
artifactId: string,
@@ -98,14 +116,6 @@ const resolveArtifactCode = (
});
});
-/**
- * Record a paused artifact call so a later request can honour the approval.
- *
- * Best-effort by design: the in-memory pause is still live and still the fast
- * path, so a storage hiccup here must not turn a working approval into a failed
- * execution. It degrades to exactly the behaviour we had before this record
- * existed.
- */
const recordPendingApproval = (approval: {
readonly executionId: string;
readonly artifactId: string;
@@ -119,22 +129,10 @@ const recordPendingApproval = (approval: {
.pipe(Effect.catchCause(() => Effect.void));
});
-/**
- * Honour an approval whose paused fiber is no longer reachable.
- *
- * The record is read through the caller's OWN scoped executor, so an execution
- * id that is not this caller's reads as absent — the same-query ownership rule
- * the artifact path already uses. It is consumed on read, so one approval
- * authorizes exactly one invocation.
- *
- * A non-accept answer discards the record and reports the decline, which is the
- * same observable outcome as declining a live pause: nothing runs.
- */
const resumeFromPendingApproval = (executionId: string, action: "accept" | "decline" | "cancel") =>
Effect.gen(function* () {
const executor = yield* ExecutorService;
const engine = yield* ExecutionEngineService;
-
const approval = yield* executor.pendingApprovals
.consume(executionId)
.pipe(Effect.catchCause(() => Effect.succeed(null)));
@@ -144,87 +142,169 @@ const resumeFromPendingApproval = (executionId: string, action: "accept" | "decl
return {
status: "completed" as const,
text: `Approval ${action === "decline" ? "declined" : "cancelled"}. Nothing ran.`,
- structured: { status: "declined", executionId, address: approval.address },
+ structured: {
+ status: "declined",
+ executionId,
+ address: approval.address,
+ },
isError: false,
};
}
- // The human approved, so run the recorded call with the gate satisfied.
- // `code` is the address the server itself resolved at pause time, so this
- // runs exactly the call that was described in the approval prompt.
const outcome = yield* captureEngineError(
- engine.executeWithPause(approval.code, { autoApprove: true }),
+ engine.executeWithPause(approval.code, {
+ autoApprove: true,
+ executionId,
+ }),
);
-
if (outcome.status === "completed") {
const formatted = formatExecuteResult(outcome.result);
- return {
- status: "completed" as const,
- text: formatted.text,
- structured: formatted.structured,
- isError: formatted.isError,
- };
+ return { status: "completed" as const, ...formatted };
}
-
- // `autoApprove` accepts every gate inline, so a second pause is not
- // reachable here; surface it rather than silently dropping the call.
const formatted = formatPausedExecution(outcome.execution);
- return {
- status: "paused" as const,
- text: formatted.text,
- structured: formatted.structured,
- };
+ return { status: "paused" as const, ...formatted };
});
+const completedReceipt = (
+ execution: RunningExecution | PausedReceipt,
+ formatted: {
+ readonly text: string;
+ readonly structured: unknown;
+ readonly isError: boolean;
+ },
+) =>
+ Effect.gen(function* () {
+ const resultHash = yield* digest(formatted);
+ const completedAt = yield* Clock.currentTimeMillis;
+ return CompletedExecutionReceipt.make({
+ executionId: execution.executionId,
+ idempotencyKey: execution.idempotencyKey,
+ requestHash: execution.requestHash,
+ startedAt: execution.startedAt,
+ status: "completed",
+ ...formatted,
+ resultHash,
+ completedAt,
+ });
+ });
+
+const pausedReceipt = (
+ execution: RunningExecution | PausedReceipt,
+ formatted: { readonly text: string; readonly structured: unknown },
+ pauseSequence: number,
+) => {
+ const structured =
+ typeof formatted.structured === "object" &&
+ formatted.structured !== null &&
+ !Array.isArray(formatted.structured)
+ ? { ...formatted.structured, pauseSequence }
+ : formatted.structured;
+ return PausedExecutionReceipt.make({
+ executionId: execution.executionId,
+ idempotencyKey: execution.idempotencyKey,
+ requestHash: execution.requestHash,
+ startedAt: execution.startedAt,
+ status: "paused",
+ text: formatted.text,
+ structured,
+ pauseSequence,
+ });
+};
+
+const replayOrConflict = (
+ execution: RunningExecution | ExecutionReceipt,
+ idempotencyKey: ExecutionIdempotencyKey,
+ requestHash: string,
+) => {
+ if (execution.idempotencyKey !== idempotencyKey || execution.requestHash !== requestHash) {
+ return Effect.fail(
+ new ExecutionIdempotencyConflictError({
+ executionId: execution.executionId,
+ idempotencyKey,
+ }),
+ );
+ }
+ if (execution.status === "running") {
+ return Effect.fail(new ExecutionInProgressError({ executionId: execution.executionId }));
+ }
+ return Effect.succeed(execution);
+};
+
export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) =>
handlers
- .handle("getPaused", ({ params: path }) =>
+ .handle("get", ({ params: path }) =>
capture(
Effect.gen(function* () {
- const engine = yield* ExecutionEngineService;
- const paused = yield* captureEngineError(engine.getPausedExecution(path.executionId));
-
- if (!paused) {
- return yield* new ExecutionNotFoundError({ executionId: path.executionId });
+ const executor = yield* ExecutorService;
+ const execution = yield* executor.executionReceipts.get(path.executionId);
+ if (execution === null) {
+ return yield* new ExecutionNotFoundError({
+ executionId: path.executionId,
+ });
}
-
- return formatPausedExecution(paused);
+ if (execution.status === "running") {
+ return yield* new ExecutionInProgressError({
+ executionId: path.executionId,
+ });
+ }
+ if (execution.status === "paused") {
+ const resume = yield* executor.executionReceipts.getResume(
+ execution.executionId,
+ execution.pauseSequence,
+ );
+ if (resume?.status === "running") {
+ return yield* new ExecutionInProgressError({
+ executionId: path.executionId,
+ });
+ }
+ }
+ return execution;
}),
),
)
.handle("execute", ({ payload }) =>
capture(
Effect.gen(function* () {
+ const executor = yield* ExecutorService;
const engine = yield* ExecutionEngineService;
- // An artifact-originated request is not arbitrary code. It is parsed
- // against the shell proxy's one grammar and rewritten through the
- // artifact's connection bindings, exactly as `execute-action` does in
- // the MCP host — the console's artifact page must not be a wider door
- // onto the same iframe.
const code =
payload.artifactId === undefined
? payload.code
: yield* resolveArtifactCode(payload.code, payload.artifactId);
+ const requestHash = yield* digest({
+ code: payload.code,
+ autoApprove: payload.autoApprove,
+ artifactId: payload.artifactId,
+ });
+ const startedAt = yield* Clock.currentTimeMillis;
+ const reservation = yield* executor.executionReceipts.reserve({
+ idempotencyKey: payload.idempotencyKey,
+ requestHash,
+ startedAt,
+ });
+ if (!reservation.created) {
+ return yield* replayOrConflict(
+ reservation.execution,
+ payload.idempotencyKey,
+ requestHash,
+ );
+ }
+
const outcome = yield* captureEngineError(
- engine.executeWithPause(code, { autoApprove: payload.autoApprove }),
+ engine.executeWithPause(code, {
+ autoApprove: payload.autoApprove,
+ executionId: reservation.execution.executionId,
+ }),
);
-
if (outcome.status === "completed") {
- const formatted = formatExecuteResult(outcome.result);
- return {
- status: "completed" as const,
- text: formatted.text,
- structured: formatted.structured,
- isError: formatted.isError,
- };
+ const receipt = yield* completedReceipt(
+ reservation.execution,
+ formatExecuteResult(outcome.result),
+ );
+ yield* executor.executionReceipts.put(receipt);
+ return receipt;
}
- // The pause is a live fiber in THIS engine, which on a host that
- // builds an engine per request is gone the moment this response is
- // written. Record the resolved call so the approval can be honoured by
- // whichever instance serves the resume. Artifact calls only: a general
- // codemode pause can sit anywhere inside arbitrary code and is not
- // reconstructible from one address.
if (payload.artifactId !== undefined) {
yield* recordPendingApproval({
executionId: outcome.execution.id,
@@ -233,52 +313,154 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions"
address: String(outcome.execution.elicitationContext.address),
});
}
-
- const formatted = formatPausedExecution(outcome.execution);
- return {
- status: "paused" as const,
- text: formatted.text,
- structured: formatted.structured,
- };
+ const receipt = pausedReceipt(
+ reservation.execution,
+ formatPausedExecution(outcome.execution),
+ 0,
+ );
+ yield* executor.executionReceipts.put(receipt);
+ return receipt;
}),
),
)
.handle("resume", ({ params: path, payload }) =>
capture(
Effect.gen(function* () {
+ const executor = yield* ExecutorService;
const engine = yield* ExecutionEngineService;
+ const execution = yield* executor.executionReceipts.get(path.executionId);
+ if (execution === null) {
+ return yield* new ExecutionNotFoundError({
+ executionId: path.executionId,
+ });
+ }
+ if (execution.status === "running") {
+ return yield* new ExecutionInProgressError({
+ executionId: path.executionId,
+ });
+ }
+
+ const requestHash = yield* digest({
+ action: payload.action,
+ content: payload.content,
+ });
+ const prior = yield* executor.executionReceipts.getResume(
+ path.executionId,
+ payload.pauseSequence,
+ );
+ if (prior !== null) {
+ if (
+ prior.idempotencyKey !== payload.idempotencyKey ||
+ prior.requestHash !== requestHash
+ ) {
+ return yield* new ExecutionResumeConflictError({
+ executionId: path.executionId,
+ pauseSequence: payload.pauseSequence,
+ });
+ }
+ if (prior.status === "settled") return prior.response;
+ if (
+ execution.status === "completed" ||
+ (execution.status === "paused" && execution.pauseSequence > payload.pauseSequence)
+ ) {
+ const settled = SettledResumeReservation.make({
+ ...prior,
+ status: "settled",
+ response: execution,
+ completedAt: yield* Clock.currentTimeMillis,
+ });
+ yield* executor.executionReceipts.settleResume(settled);
+ return execution;
+ }
+ return yield* new ExecutionInProgressError({
+ executionId: path.executionId,
+ });
+ }
+
+ if (execution.status === "completed") return execution;
+ if (execution.pauseSequence !== payload.pauseSequence) {
+ return yield* new ExecutionResumeConflictError({
+ executionId: path.executionId,
+ pauseSequence: payload.pauseSequence,
+ });
+ }
+
+ const resumeStartedAt = yield* Clock.currentTimeMillis;
+ const reservation = RunningResumeReservation.make({
+ status: "running",
+ executionId: path.executionId,
+ pauseSequence: payload.pauseSequence,
+ idempotencyKey: payload.idempotencyKey,
+ requestHash,
+ startedAt: resumeStartedAt,
+ });
+ const reserved = yield* executor.executionReceipts.reserveResume(reservation);
+ if (!reserved.created) {
+ if (
+ reserved.reservation.idempotencyKey !== payload.idempotencyKey ||
+ reserved.reservation.requestHash !== requestHash
+ ) {
+ return yield* new ExecutionResumeConflictError({
+ executionId: path.executionId,
+ pauseSequence: payload.pauseSequence,
+ });
+ }
+ if (reserved.reservation.status === "settled") return reserved.reservation.response;
+ return yield* new ExecutionInProgressError({
+ executionId: path.executionId,
+ });
+ }
+
const result = yield* captureEngineError(
engine.resume(path.executionId, {
action: payload.action,
- content: payload.content as Record | undefined,
+ content: payload.content,
}),
);
-
- // No live pause: either this resume landed on a different engine
- // instance than the pause did (the normal case on a host that builds
- // an engine per request), or the window really has closed.
- if (!result) {
- const honoured = yield* resumeFromPendingApproval(path.executionId, payload.action);
- if (honoured) return honoured;
- return yield* new ApprovalExpiredError({ executionId: path.executionId });
- }
-
- if (result.status === "completed") {
- const formatted = formatExecuteResult(result.result);
- return {
- status: "completed" as const,
- text: formatted.text,
- structured: formatted.structured,
- isError: formatted.isError,
- };
+ const recovered =
+ result ?? (yield* resumeFromPendingApproval(path.executionId, payload.action));
+ if (!recovered) {
+ yield* executor.executionReceipts.discardResume(
+ path.executionId,
+ payload.pauseSequence,
+ );
+ return yield* new ApprovalExpiredError({
+ executionId: path.executionId,
+ });
}
- const formatted = formatPausedExecution(result.execution);
- return {
- status: "paused" as const,
- text: formatted.text,
- structured: formatted.structured,
- };
+ const response =
+ recovered.status === "completed"
+ ? yield* completedReceipt(
+ execution,
+ "result" in recovered
+ ? formatExecuteResult(recovered.result)
+ : {
+ text: recovered.text,
+ structured: recovered.structured,
+ isError: recovered.isError,
+ },
+ )
+ : pausedReceipt(
+ execution,
+ "execution" in recovered
+ ? formatPausedExecution(recovered.execution)
+ : {
+ text: recovered.text,
+ structured: recovered.structured,
+ },
+ execution.pauseSequence + 1,
+ );
+ yield* executor.executionReceipts.put(response);
+ yield* executor.executionReceipts.settleResume(
+ SettledResumeReservation.make({
+ ...reservation,
+ status: "settled",
+ response,
+ completedAt: yield* Clock.currentTimeMillis,
+ }),
+ );
+ return response;
}),
),
),
diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts
index eac50f175c..b32209ed02 100644
--- a/packages/core/api/src/server/execution-stack-middleware.ts
+++ b/packages/core/api/src/server/execution-stack-middleware.ts
@@ -328,12 +328,12 @@ class PlatformEngineUnavailable extends Data.TaggedError("PlatformEngineUnavaila
}> {}
/**
- * The engine the platform branch provides. The one member a safe read can
- * actually reach — `getPausedExecution`, via `GET /executions/:id` — answers
- * null (a 404), honestly: the platform view owns no executions. The paused
- * counters answer the same empty story for any future reader. Everything else
- * (execute, resume, the MCP tool description) exists to satisfy the service
- * shape, sits behind the middleware's safe-request gate, and dies if reached.
+ * The engine the platform branch provides. Execution receipt reads use the
+ * owner-scoped executor store, where this subject-less view owns no executions,
+ * so no safe HTTP read reaches an engine member. The paused counters answer the
+ * same empty story for any future reader. Everything else (execute, resume, the
+ * MCP tool description) exists to satisfy the service shape, sits behind the
+ * middleware's safe-request gate, and dies if reached.
*/
const readOnlyExecutionEngine: ExecutionEngine = {
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome
diff --git a/packages/core/api/tsup.config.ts b/packages/core/api/tsup.config.ts
new file mode 100644
index 0000000000..08987ad99d
--- /dev/null
+++ b/packages/core/api/tsup.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: {
+ client: "src/client.ts",
+ },
+ format: ["esm"],
+ dts: {
+ resolve: true,
+ },
+ sourcemap: true,
+ clean: true,
+ external: [/^@executor-js\//, /^effect/, /^@effect\//],
+ noExternal: ["@executor-js/sdk/shared"],
+});
diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts
index 5113f9f462..8d9bbc26cb 100644
--- a/packages/core/execution/src/engine.ts
+++ b/packages/core/execution/src/engine.ts
@@ -478,7 +478,7 @@ export type ExecutionEngine
*/
readonly executeWithPause: (
code: string,
- options?: { readonly autoApprove?: boolean },
+ options?: { readonly autoApprove?: boolean; readonly executionId?: string },
) => Effect.Effect;
/**
@@ -599,7 +599,10 @@ export const createExecutionEngine = ({ status: "completed", result })),
),
Queue.take(pauseQueue).pipe(
- Effect.map((paused): ExecutionResult => ({ status: "paused", execution: paused })),
+ Effect.map((paused): ExecutionResult => {
+ pausedExecutions.set(paused.id, paused);
+ return { status: "paused", execution: paused };
+ }),
),
);
@@ -611,7 +614,7 @@ export const createExecutionEngine = = {
id,
@@ -651,8 +654,6 @@ export const createExecutionEngine =
Effect.gen(function* () {
- recordSettledOutcome(executionId, exit);
+ // A caller-supplied id remains the identity of the whole execution,
+ // including later pauses. Do not cache an intermediate pause under
+ // that same id or the next resume would replay it forever. HTTP
+ // resume idempotency is persisted separately per pause sequence.
+ if (
+ !(
+ Exit.isSuccess(exit) &&
+ exit.value.status === "paused" &&
+ exit.value.execution.id === executionId
+ )
+ ) {
+ recordSettledOutcome(executionId, exit);
+ }
pendingResumes.delete(executionId);
yield* Deferred.done(inflight, exit);
}),
diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts
index ee6eeda500..1c71fc4d43 100644
--- a/packages/core/execution/src/promise.ts
+++ b/packages/core/execution/src/promise.ts
@@ -43,7 +43,7 @@ export type ExecutionEngine = {
) => Promise;
readonly executeWithPause: (
code: string,
- options?: { readonly autoApprove?: boolean },
+ options?: { readonly autoApprove?: boolean; readonly executionId?: string },
) => Promise;
readonly resume: (
executionId: string,
diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts
index 747bd23105..f2e8be2eb1 100644
--- a/packages/core/execution/src/tool-invoker.test.ts
+++ b/packages/core/execution/src/tool-invoker.test.ts
@@ -1730,6 +1730,36 @@ describe("pause/resume with multiple elicitations", () => {
{ timeout: 10000 },
);
+ it.effect(
+ "preserves a caller-supplied execution id across pauses",
+ () =>
+ Effect.gen(function* () {
+ const executor = yield* makeElicitingExecutor();
+ const engine = createExecutionEngine({ executor, codeExecutor });
+ const executionId = "exec_stable_receipt";
+ const code = `
+ return await Promise.all([
+ tools.api.org.main.singleApproval({}),
+ tools.api.org.main.singleApproval({})
+ ]);
+ `;
+
+ const first = yield* engine.executeWithPause(code, { executionId });
+ expect(first.status).toBe("paused");
+ if (first.status !== "paused") return;
+ expect(first.execution.id).toBe(executionId);
+
+ const second = yield* engine.resume(executionId, { action: "accept" });
+ expect(second?.status).toBe("paused");
+ if (second?.status !== "paused") return;
+ expect(second.execution.id).toBe(executionId);
+
+ const completed = yield* engine.resume(executionId, { action: "accept" });
+ expect(completed?.status).toBe("completed");
+ }),
+ { timeout: 10000 },
+ );
+
it.effect(
"a duplicate resume replays the delivered outcome instead of reporting a missing pause",
() =>
diff --git a/packages/core/sdk/src/blob.test.ts b/packages/core/sdk/src/blob.test.ts
index 59e5a75d12..ac71d7a56c 100644
--- a/packages/core/sdk/src/blob.test.ts
+++ b/packages/core/sdk/src/blob.test.ts
@@ -123,3 +123,14 @@ describe("BlobStore.getMany", () => {
}),
);
});
+
+describe("BlobStore.putIfAbsent", () => {
+ it.effect("keeps the first value", () =>
+ Effect.gen(function* () {
+ const store = makeInMemoryBlobStore();
+ expect(yield* store.putIfAbsent("ns", "key", "first")).toBe(true);
+ expect(yield* store.putIfAbsent("ns", "key", "second")).toBe(false);
+ expect(yield* store.get("ns", "key")).toBe("first");
+ }),
+ );
+});
diff --git a/packages/core/sdk/src/blob.ts b/packages/core/sdk/src/blob.ts
index c98286611d..b4be5e4820 100644
--- a/packages/core/sdk/src/blob.ts
+++ b/packages/core/sdk/src/blob.ts
@@ -36,6 +36,13 @@ export interface BlobStore {
key: string,
value: string,
) => Effect.Effect;
+ /** Store a value only when the key is absent. Returns true for the writer
+ * that created the key and false when another writer already owns it. */
+ readonly putIfAbsent: (
+ namespace: string,
+ key: string,
+ value: string,
+ ) => Effect.Effect;
readonly delete: (namespace: string, key: string) => Effect.Effect;
readonly has: (namespace: string, key: string) => Effect.Effect;
}
@@ -149,6 +156,13 @@ export const makeInMemoryBlobStore = (): BlobStore => {
Effect.sync(() => {
store.set(k(ns, key), value);
}),
+ putIfAbsent: (ns, key, value) =>
+ Effect.sync(() => {
+ const id = k(ns, key);
+ if (store.has(id)) return false;
+ store.set(id, value);
+ return true;
+ }),
delete: (ns, key) =>
Effect.sync(() => {
store.delete(k(ns, key));
@@ -232,6 +246,18 @@ export const makeFumaBlobStore = (fuma: IFumaClient): BlobStore => ({
(cause) => new StorageError({ message: "FumaDB blob operation failed", cause }),
),
),
+ putIfAbsent: (namespace, key, value) =>
+ fuma
+ .use("blob.createIfAbsent", (db) =>
+ db.create("blob", { id: blobId(namespace, key), namespace, key, value }),
+ )
+ .pipe(
+ Effect.as(true),
+ Effect.catchTag("UniqueViolationError", () => Effect.succeed(false)),
+ Effect.mapError(
+ (cause) => new StorageError({ message: "FumaDB blob operation failed", cause }),
+ ),
+ ),
delete: (namespace, key) =>
fuma
.use("blob.delete", (db) =>
diff --git a/packages/core/sdk/src/execution-receipt.test.ts b/packages/core/sdk/src/execution-receipt.test.ts
new file mode 100644
index 0000000000..82c9a2a383
--- /dev/null
+++ b/packages/core/sdk/src/execution-receipt.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "@effect/vitest";
+import { Effect } from "effect";
+
+import { makeInMemoryBlobStore } from "./blob";
+import {
+ CompletedExecutionReceipt,
+ ExecutionId,
+ ExecutionIdempotencyKey,
+ RunningResumeReservation,
+ makeExecutionReceiptStore,
+} from "./execution-receipt";
+import { StorageError } from "./fuma-runtime";
+
+const hash = `sha256:${"a".repeat(64)}`;
+
+describe("ExecutionReceiptStore", () => {
+ it.effect("atomically reserves a stable owner-scoped execution id", () =>
+ Effect.gen(function* () {
+ const blobs = makeInMemoryBlobStore();
+ const store = makeExecutionReceiptStore(blobs, "u:tenant:subject");
+ const idempotencyKey = ExecutionIdempotencyKey.make("request-1");
+
+ const first = yield* store.reserve({
+ idempotencyKey,
+ requestHash: hash,
+ startedAt: 1,
+ });
+ const replay = yield* store.reserve({
+ idempotencyKey,
+ requestHash: hash,
+ startedAt: 2,
+ });
+
+ expect(first.created).toBe(true);
+ expect(replay.created).toBe(false);
+ expect(replay.execution.executionId).toBe(first.execution.executionId);
+ expect(replay.execution.startedAt).toBe(1);
+ }),
+ );
+
+ it.effect("isolates identical keys by owner partition", () =>
+ Effect.gen(function* () {
+ const blobs = makeInMemoryBlobStore();
+ const first = makeExecutionReceiptStore(blobs, "u:tenant:first");
+ const second = makeExecutionReceiptStore(blobs, "u:tenant:second");
+ const idempotencyKey = ExecutionIdempotencyKey.make("request-1");
+ const reserved = yield* first.reserve({
+ idempotencyKey,
+ requestHash: hash,
+ startedAt: 1,
+ });
+
+ expect(yield* second.get(reserved.execution.executionId)).toBeNull();
+ }),
+ );
+
+ it.effect("admits one resume per pause sequence", () =>
+ Effect.gen(function* () {
+ const store = makeExecutionReceiptStore(makeInMemoryBlobStore(), "u:t:s");
+ const execution = yield* store.reserve({
+ idempotencyKey: ExecutionIdempotencyKey.make("execute"),
+ requestHash: hash,
+ startedAt: 1,
+ });
+ const resume = RunningResumeReservation.make({
+ status: "running",
+ executionId: execution.execution.executionId,
+ pauseSequence: 0,
+ idempotencyKey: ExecutionIdempotencyKey.make("resume"),
+ requestHash: hash,
+ startedAt: 2,
+ });
+
+ expect((yield* store.reserveResume(resume)).created).toBe(true);
+ expect((yield* store.reserveResume(resume)).created).toBe(false);
+ }),
+ );
+
+ it.effect("never overwrites a completed receipt", () =>
+ Effect.gen(function* () {
+ const store = makeExecutionReceiptStore(makeInMemoryBlobStore(), "u:t:s");
+ const reserved = yield* store.reserve({
+ idempotencyKey: ExecutionIdempotencyKey.make("immutable"),
+ requestHash: hash,
+ startedAt: 1,
+ });
+ const completed = CompletedExecutionReceipt.make({
+ ...reserved.execution,
+ status: "completed",
+ text: "first",
+ structured: { value: 1 },
+ isError: false,
+ resultHash: hash,
+ completedAt: 2,
+ });
+ yield* store.put(completed);
+ yield* store.put({ ...completed, text: "second", completedAt: 3 });
+
+ expect(yield* store.get(completed.executionId)).toStrictEqual(completed);
+ }),
+ );
+
+ it.effect("refuses malformed stored receipts", () =>
+ Effect.gen(function* () {
+ const blobs = makeInMemoryBlobStore();
+ const store = makeExecutionReceiptStore(blobs, "u:t:s");
+ const executionId = ExecutionId.make("exec_invalid");
+ yield* blobs.put("u:t:s/@execution", executionId, "not json");
+
+ const error = yield* store.get(executionId).pipe(Effect.flip);
+ expect(error).toBeInstanceOf(StorageError);
+ }),
+ );
+});
diff --git a/packages/core/sdk/src/execution-receipt.ts b/packages/core/sdk/src/execution-receipt.ts
new file mode 100644
index 0000000000..5cec26c072
--- /dev/null
+++ b/packages/core/sdk/src/execution-receipt.ts
@@ -0,0 +1,218 @@
+import { Effect, Schema } from "effect";
+
+import { sha256Hex, type BlobStore } from "./blob";
+import { StorageError } from "./fuma-runtime";
+
+export const ExecutionId = Schema.String.check(
+ Schema.isLengthBetween(6, 133),
+ Schema.isPattern(/^exec_[A-Za-z0-9_-]+$/),
+);
+export type ExecutionId = typeof ExecutionId.Type;
+
+export const ExecutionIdempotencyKey = Schema.String.check(Schema.isLengthBetween(1, 200));
+export type ExecutionIdempotencyKey = typeof ExecutionIdempotencyKey.Type;
+
+const Sha256 = Schema.String.check(Schema.isPattern(/^sha256:[a-f0-9]{64}$/));
+export const ExecutionPauseSequence = Schema.Number.check(
+ Schema.isInt(),
+ Schema.isGreaterThanOrEqualTo(0),
+);
+
+const ExecutionBase = {
+ executionId: ExecutionId,
+ idempotencyKey: ExecutionIdempotencyKey,
+ requestHash: Sha256,
+ startedAt: Schema.Number,
+};
+
+export const RunningExecution = Schema.Struct({
+ ...ExecutionBase,
+ status: Schema.Literal("running"),
+});
+export type RunningExecution = typeof RunningExecution.Type;
+
+export const PausedExecutionReceipt = Schema.Struct({
+ ...ExecutionBase,
+ status: Schema.Literal("paused"),
+ text: Schema.String,
+ structured: Schema.Unknown,
+ pauseSequence: ExecutionPauseSequence,
+});
+export type PausedExecutionReceipt = typeof PausedExecutionReceipt.Type;
+
+export const CompletedExecutionReceipt = Schema.Struct({
+ ...ExecutionBase,
+ status: Schema.Literal("completed"),
+ text: Schema.String,
+ structured: Schema.Unknown,
+ isError: Schema.Boolean,
+ resultHash: Sha256,
+ completedAt: Schema.Number,
+});
+export type CompletedExecutionReceipt = typeof CompletedExecutionReceipt.Type;
+
+export const ExecutionReceipt = Schema.Union([PausedExecutionReceipt, CompletedExecutionReceipt]);
+export type ExecutionReceipt = typeof ExecutionReceipt.Type;
+
+export const StoredExecution = Schema.Union([
+ RunningExecution,
+ PausedExecutionReceipt,
+ CompletedExecutionReceipt,
+]);
+export type StoredExecution = typeof StoredExecution.Type;
+
+const ResumeReservationBase = {
+ executionId: ExecutionId,
+ pauseSequence: ExecutionPauseSequence,
+ idempotencyKey: ExecutionIdempotencyKey,
+ requestHash: Sha256,
+ startedAt: Schema.Number,
+};
+
+export const RunningResumeReservation = Schema.Struct({
+ ...ResumeReservationBase,
+ status: Schema.Literal("running"),
+});
+export type RunningResumeReservation = typeof RunningResumeReservation.Type;
+
+export const SettledResumeReservation = Schema.Struct({
+ ...ResumeReservationBase,
+ status: Schema.Literal("settled"),
+ response: ExecutionReceipt,
+ completedAt: Schema.Number,
+});
+export type SettledResumeReservation = typeof SettledResumeReservation.Type;
+
+export const ResumeReservation = Schema.Union([RunningResumeReservation, SettledResumeReservation]);
+export type ResumeReservation = typeof ResumeReservation.Type;
+
+const encodeExecution = Schema.encodeUnknownEffect(Schema.fromJsonString(StoredExecution));
+const decodeExecution = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredExecution));
+const encodeResume = Schema.encodeUnknownEffect(Schema.fromJsonString(ResumeReservation));
+const decodeResume = Schema.decodeUnknownEffect(Schema.fromJsonString(ResumeReservation));
+
+const invalidStoredReceipt = (cause: unknown): StorageError =>
+ new StorageError({ message: "Stored execution receipt is invalid", cause });
+
+const executionKeyFor = (partition: string, idempotencyKey: string) =>
+ sha256Hex(`${partition}\u0000${idempotencyKey}`).pipe(
+ Effect.map((hash) => ExecutionId.make(`exec_${hash}`)),
+ );
+
+export interface ExecutionReceiptStore {
+ readonly reserve: (input: {
+ readonly idempotencyKey: ExecutionIdempotencyKey;
+ readonly requestHash: string;
+ readonly startedAt: number;
+ }) => Effect.Effect<
+ | { readonly created: true; readonly execution: RunningExecution }
+ | { readonly created: false; readonly execution: StoredExecution },
+ StorageError
+ >;
+ readonly get: (executionId: ExecutionId) => Effect.Effect;
+ readonly put: (execution: StoredExecution) => Effect.Effect;
+ readonly reserveResume: (input: RunningResumeReservation) => Effect.Effect<
+ {
+ readonly created: boolean;
+ readonly reservation: ResumeReservation;
+ },
+ StorageError
+ >;
+ readonly getResume: (
+ executionId: ExecutionId,
+ pauseSequence: number,
+ ) => Effect.Effect;
+ readonly settleResume: (
+ reservation: SettledResumeReservation,
+ ) => Effect.Effect;
+ readonly discardResume: (
+ executionId: ExecutionId,
+ pauseSequence: number,
+ ) => Effect.Effect;
+}
+
+export const makeExecutionReceiptStore = (
+ blobs: BlobStore,
+ partition: string,
+): ExecutionReceiptStore => {
+ const executionNamespace = `${partition}/@execution`;
+ const resumeNamespace = `${partition}/@execution-resume`;
+
+ const readExecution = (executionId: ExecutionId) =>
+ Effect.gen(function* () {
+ const raw = yield* blobs.get(executionNamespace, executionId);
+ if (raw === null) return null;
+ return yield* decodeExecution(raw).pipe(Effect.mapError(invalidStoredReceipt));
+ });
+
+ const readResume = (executionId: ExecutionId, pauseSequence: number) =>
+ Effect.gen(function* () {
+ const raw = yield* blobs.get(resumeNamespace, `${executionId}:${pauseSequence}`);
+ if (raw === null) return null;
+ return yield* decodeResume(raw).pipe(Effect.mapError(invalidStoredReceipt));
+ });
+
+ return {
+ reserve: (input) =>
+ Effect.gen(function* () {
+ const executionId = yield* executionKeyFor(partition, input.idempotencyKey);
+ const candidate = RunningExecution.make({
+ ...input,
+ executionId,
+ status: "running",
+ });
+ const encoded = yield* encodeExecution(candidate).pipe(
+ Effect.mapError(invalidStoredReceipt),
+ );
+ const created = yield* blobs.putIfAbsent(executionNamespace, executionId, encoded);
+ if (created) return { created: true as const, execution: candidate };
+ const existing = yield* readExecution(executionId);
+ if (existing === null) {
+ return yield* new StorageError({
+ message: "Execution reservation disappeared after creation conflict",
+ cause: undefined,
+ });
+ }
+ return { created: false as const, execution: existing };
+ }),
+ get: readExecution,
+ put: (execution) =>
+ Effect.gen(function* () {
+ const existing = yield* readExecution(execution.executionId);
+ if (existing?.status === "completed") return;
+ const encoded = yield* encodeExecution(execution).pipe(
+ Effect.mapError(invalidStoredReceipt),
+ );
+ yield* blobs.put(executionNamespace, execution.executionId, encoded);
+ }),
+ reserveResume: (input) =>
+ Effect.gen(function* () {
+ const key = `${input.executionId}:${input.pauseSequence}`;
+ const encoded = yield* encodeResume(input).pipe(Effect.mapError(invalidStoredReceipt));
+ const created = yield* blobs.putIfAbsent(resumeNamespace, key, encoded);
+ if (created) return { created, reservation: input };
+ const existing = yield* readResume(input.executionId, input.pauseSequence);
+ if (existing === null) {
+ return yield* new StorageError({
+ message: "Resume reservation disappeared after creation conflict",
+ cause: undefined,
+ });
+ }
+ return { created, reservation: existing };
+ }),
+ getResume: readResume,
+ settleResume: (reservation) =>
+ encodeResume(reservation).pipe(
+ Effect.mapError(invalidStoredReceipt),
+ Effect.flatMap((encoded) =>
+ blobs.put(
+ resumeNamespace,
+ `${reservation.executionId}:${reservation.pauseSequence}`,
+ encoded,
+ ),
+ ),
+ ),
+ discardResume: (executionId, pauseSequence) =>
+ blobs.delete(resumeNamespace, `${executionId}:${pauseSequence}`),
+ };
+};
diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts
index b31e60149c..2a4b6acaaa 100644
--- a/packages/core/sdk/src/executor.ts
+++ b/packages/core/sdk/src/executor.ts
@@ -31,6 +31,7 @@ import {
} from "./fuma-runtime";
import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob";
import { makePendingApprovalStore, type PendingApprovalStore } from "./pending-approval";
+import { makeExecutionReceiptStore, type ExecutionReceiptStore } from "./execution-receipt";
import { coreToolsPlugin } from "./core-tools";
import type {
Connection,
@@ -504,6 +505,9 @@ export type Executor = {
*/
readonly pendingApprovals: PendingApprovalStore;
+ /** Durable, owner-scoped execution state and terminal result receipts. */
+ readonly executionReceipts: ExecutionReceiptStore;
+
readonly execute: (
address: ToolAddress,
args: unknown,
@@ -6164,15 +6168,14 @@ export const createExecutor = ({
},
catch: storeError("put"),
}),
+ putIfAbsent: (namespace, key, value) =>
+ Effect.tryPromise({
+ try: async () => {
+ const result = await bucket.put(objectName(namespace, key), value, {
+ onlyIf: { etagDoesNotMatch: "*" },
+ });
+ return result !== null;
+ },
+ catch: storeError("putIfAbsent"),
+ }),
delete: (namespace, key) =>
Effect.tryPromise({
try: () => bucket.delete(objectName(namespace, key)),
diff --git a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts
index 6d7744d0c6..1d8982c8d4 100644
--- a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts
+++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts
@@ -9,6 +9,7 @@ export type ToolCallHost = {
export type TrustedInteraction = {
executionId: string;
+ pauseSequence?: number;
interaction: {
kind?: unknown;
message?: unknown;
@@ -129,6 +130,7 @@ async function resolveToolResult(
name: "execute-action-resume",
arguments: {
executionId: pending.executionId,
+ ...(pending.pauseSequence === undefined ? {} : { pauseSequence: pending.pauseSequence }),
action: response.action,
content: JSON.stringify(response.content ?? {}),
},
@@ -150,7 +152,13 @@ function parseTrustedInteraction(
!Array.isArray(structured.interaction)
? (structured.interaction as TrustedInteraction["interaction"])
: {};
- return { executionId: structured.executionId, interaction };
+ return {
+ executionId: structured.executionId,
+ ...(typeof structured.pauseSequence === "number"
+ ? { pauseSequence: structured.pauseSequence }
+ : {}),
+ interaction,
+ };
}
/**
diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx
index 0d6fb9af7d..217a0c04f0 100644
--- a/packages/react/src/api/atoms.tsx
+++ b/packages/react/src/api/atoms.tsx
@@ -150,7 +150,7 @@ export const policiesAtom = ExecutorApiClient.query("policies", "list", {
});
export const pausedExecutionAtom = (executionId: string) =>
- ExecutorApiClient.query("executions", "getPaused", {
+ ExecutorApiClient.query("executions", "get", {
params: { executionId },
timeToLive: "5 seconds",
});
diff --git a/packages/react/src/api/shell-host.test.ts b/packages/react/src/api/shell-host.test.ts
index 70601bb60e..ff0fbeaeda 100644
--- a/packages/react/src/api/shell-host.test.ts
+++ b/packages/react/src/api/shell-host.test.ts
@@ -58,7 +58,11 @@ describe("createHttpShellHost", () => {
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toContain("/executions");
- expect(calls[0]?.body).toStrictEqual({ code: "return await tools.a.b()", artifactId: "art_1" });
+ expect(calls[0]?.body).toStrictEqual({
+ idempotencyKey: expect.any(String),
+ code: "return await tools.a.b()",
+ artifactId: "art_1",
+ });
expect(result.isError).toBeUndefined();
});
@@ -70,11 +74,21 @@ describe("createHttpShellHost", () => {
await host.callServerTool({
name: "execute-action-resume",
- arguments: { executionId: "exec_1", action: "accept", content: '{"note":"hi"}' },
+ arguments: {
+ executionId: "exec_1",
+ pauseSequence: 0,
+ action: "accept",
+ content: '{"note":"hi"}',
+ },
});
expect(calls[0]?.url).toContain("/executions/exec_1/resume");
- expect(calls[0]?.body).toStrictEqual({ action: "accept", content: { note: "hi" } });
+ expect(calls[0]?.body).toStrictEqual({
+ idempotencyKey: expect.any(String),
+ pauseSequence: 0,
+ action: "accept",
+ content: { note: "hi" },
+ });
});
// The reported bug's user-visible half: the approval failed and the person
@@ -88,7 +102,7 @@ describe("createHttpShellHost", () => {
await expect(
host.callServerTool({
name: "execute-action-resume",
- arguments: { executionId: "exec_1", action: "accept", content: "{}" },
+ arguments: { executionId: "exec_1", pauseSequence: 0, action: "accept", content: "{}" },
}),
).rejects.toThrow(APPROVAL_EXPIRED_MESSAGE);
});
@@ -112,12 +126,21 @@ describe("createHttpShellHost", () => {
await host.callServerTool({
name: "execute-action-resume",
- arguments: { executionId: "exec_1", action: "decline", content: "{not json" },
+ arguments: {
+ executionId: "exec_1",
+ pauseSequence: 0,
+ action: "decline",
+ content: "{not json",
+ },
});
// `content` is absent rather than null: an unparseable body is dropped, but
// the decision still travels.
- expect(calls[0]?.body).toStrictEqual({ action: "decline" });
+ expect(calls[0]?.body).toStrictEqual({
+ idempotencyKey: expect.any(String),
+ pauseSequence: 0,
+ action: "decline",
+ });
});
// The prod outage this file's fix addresses. An org-scoped host (cloud) fails
@@ -146,7 +169,12 @@ describe("createHttpShellHost", () => {
await createHttpShellHost({ fetch }).callServerTool({
name: "execute-action-resume",
- arguments: { executionId: "exec_1", action: "accept", content: "{}" },
+ arguments: {
+ executionId: "exec_1",
+ pauseSequence: 0,
+ action: "accept",
+ content: "{}",
+ },
});
expect(calls[0]?.headers[EXECUTOR_ORG_HEADER]).toBe("acme");
diff --git a/packages/react/src/api/shell-host.ts b/packages/react/src/api/shell-host.ts
index 1605e994fc..c28bceb7e0 100644
--- a/packages/react/src/api/shell-host.ts
+++ b/packages/react/src/api/shell-host.ts
@@ -29,12 +29,19 @@ import {
/** The wire shape of `POST /executions` and `POST /executions/:id/resume`. */
type ExecutionResponse =
| {
+ readonly executionId: string;
readonly status: "completed";
readonly text: string;
readonly structured: unknown;
readonly isError: boolean;
}
- | { readonly status: "paused"; readonly text: string; readonly structured: unknown };
+ | {
+ readonly executionId: string;
+ readonly pauseSequence: number;
+ readonly status: "paused";
+ readonly text: string;
+ readonly structured: unknown;
+ };
/** The subset of `CallToolResult` the shell reads back. */
export interface ShellToolResult {
@@ -75,7 +82,11 @@ export const APPROVAL_EXPIRED_MESSAGE = "This approval expired. Trigger the acti
const toShellToolResult = (response: ExecutionResponse): ShellToolResult => ({
content: [{ type: "text", text: response.text }],
structuredContent: isRecord(response.structured)
- ? response.structured
+ ? {
+ ...response.structured,
+ executionId: response.executionId,
+ ...(response.status === "paused" ? { pauseSequence: response.pauseSequence } : {}),
+ }
: { result: response.structured },
isError: response.status === "completed" && response.isError ? true : undefined,
});
@@ -211,6 +222,7 @@ export const createHttpShellHost = (options?: {
const artifactId = input.artifactId;
return toShellToolResult(
(await post("/executions", {
+ idempotencyKey: crypto.randomUUID(),
code,
...(typeof artifactId === "string" ? { artifactId } : {}),
})) as ExecutionResponse,
@@ -219,6 +231,7 @@ export const createHttpShellHost = (options?: {
if (name === "execute-action-resume") {
const executionId = input.executionId;
+ const pauseSequence = input.pauseSequence;
const action = input.action;
if (typeof executionId !== "string") {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above
@@ -228,8 +241,14 @@ export const createHttpShellHost = (options?: {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above
throw new Error("Invalid resume action.");
}
+ if (typeof pauseSequence !== "number") {
+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above
+ throw new Error("Missing pause sequence.");
+ }
return toShellToolResult(
(await post(`/executions/${encodeURIComponent(executionId)}/resume`, {
+ idempotencyKey: crypto.randomUUID(),
+ pauseSequence,
action,
content: parseResumeContent(input.content),
})) as ExecutionResponse,
diff --git a/packages/react/src/components/tool-run-panel.tsx b/packages/react/src/components/tool-run-panel.tsx
index 680ef7cff6..f984bb7cec 100644
--- a/packages/react/src/components/tool-run-panel.tsx
+++ b/packages/react/src/components/tool-run-panel.tsx
@@ -221,7 +221,10 @@ export function ToolRunPanel(props: {
// `autoApprove` because the operator clicked Run: that IS the approval, so
// an approval-gated tool should run here instead of dead-ending on a pause.
const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`;
- const exit = await doExecute({ payload: { code, autoApprove: true }, reactivityKeys: [] });
+ const exit = await doExecute({
+ payload: { idempotencyKey: crypto.randomUUID(), code, autoApprove: true },
+ reactivityKeys: [],
+ });
setRunning(false);
if (Exit.isFailure(exit)) {
diff --git a/packages/react/src/pages/resume-approval.tsx b/packages/react/src/pages/resume-approval.tsx
index 424e8046f9..6bbe8b07b1 100644
--- a/packages/react/src/pages/resume-approval.tsx
+++ b/packages/react/src/pages/resume-approval.tsx
@@ -12,16 +12,24 @@ import { CopyButton } from "../components/copy-button";
import { type ElicitationAction, useElicitationApproval } from "../components/elicitation-approval";
import { Skeleton } from "../components/skeleton";
-type PausedExecutionInfo = { readonly text: string; readonly structured: unknown };
+type PausedExecutionInfo = {
+ readonly text: string;
+ readonly structured: unknown;
+ readonly executionId?: string;
+ readonly pauseSequence?: number;
+};
type ResumeExecutionResult =
| {
readonly status: "completed";
+ readonly executionId?: string;
readonly text: string;
readonly structured: unknown;
readonly isError: boolean;
}
| {
readonly status: "paused";
+ readonly executionId?: string;
+ readonly pauseSequence?: number;
readonly text: string;
readonly structured: unknown;
};
@@ -122,10 +130,20 @@ export function ResumeApprovalPage(props: { executionId: string }) {
const doResume = useAtomSet(resumeExecution, { mode: "promiseExit" });
const resume = useCallback(
- (executionId: string, action: ElicitationAction, content?: Record) =>
+ (
+ executionId: string,
+ action: ElicitationAction,
+ content?: Record,
+ pauseSequence?: number,
+ ) =>
doResume({
params: { executionId },
- payload: action === "accept" ? { action, content: content ?? {} } : { action },
+ payload: {
+ idempotencyKey: crypto.randomUUID(),
+ pauseSequence: pauseSequence ?? 0,
+ action,
+ ...(action === "accept" ? { content: content ?? {} } : {}),
+ },
}),
[doResume],
);
@@ -140,6 +158,7 @@ export function ResumeApprovalPageView(props: {
executionId: string,
action: ElicitationAction,
content?: Record,
+ pauseSequence?: number,
) => Promise>;
unavailableMessage?: string;
}) {
@@ -171,7 +190,12 @@ export function ResumeApprovalPageView(props: {
if (content === null) return;
setStatus({ state: "submitting", action });
- const exit = await resume(currentExecutionId, action, content);
+ const exit = await resume(
+ currentExecutionId,
+ action,
+ content,
+ displayedPaused?.pauseSequence,
+ );
if (Exit.isFailure(exit)) {
trackEvent("resume_approval_submitted", {
@@ -185,7 +209,8 @@ export function ResumeApprovalPageView(props: {
}
if (exit.value.status === "paused") {
- const nextExecutionId = executionIdFromStructured(exit.value.structured);
+ const nextExecutionId =
+ exit.value.executionId ?? executionIdFromStructured(exit.value.structured);
if (!nextExecutionId) {
trackEvent("resume_approval_submitted", {
action,
@@ -207,7 +232,12 @@ export function ResumeApprovalPageView(props: {
success: true,
});
setCurrentExecutionId(nextExecutionId);
- setNextPaused({ text: exit.value.text, structured: exit.value.structured });
+ setNextPaused({
+ text: exit.value.text,
+ structured: exit.value.structured,
+ executionId: nextExecutionId,
+ pauseSequence: exit.value.pauseSequence,
+ });
setStatus({ state: "idle" });
return;
}
@@ -224,7 +254,7 @@ export function ResumeApprovalPageView(props: {
text: exit.value.text || "The paused execution has been resumed.",
});
},
- [approval, currentExecutionId, interaction, resume],
+ [approval, currentExecutionId, displayedPaused?.pauseSequence, interaction, resume],
);
const busy = status.state === "submitting";
diff --git a/scripts/prepare-effect.sh b/scripts/prepare-effect.sh
new file mode 100755
index 0000000000..24a9d31451
--- /dev/null
+++ b/scripts/prepare-effect.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env sh
+
+set -eu
+
+repo_dir=".repos/effect"
+repo_url="https://github.com/Effect-TS/effect-smol"
+
+if [ -d "$repo_dir/.git" ]; then
+ exit 0
+fi
+
+mkdir -p ".repos"
+git clone "$repo_url" "$repo_dir"
diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts
index 62034b988c..2dd08510f1 100644
--- a/scripts/publish-packages.ts
+++ b/scripts/publish-packages.ts
@@ -31,6 +31,7 @@ const PUBLIC_PACKAGE_DIRS = [
"packages/kernel/core",
"packages/kernel/runtime-quickjs",
"packages/core/sdk",
+ "packages/core/api",
"packages/core/config",
"packages/core/execution",
"packages/core/cli",
@@ -103,8 +104,8 @@ type MutablePackageJson = {
* Resolves `workspace:*` dependencies between public packages to concrete
* versions before packing. Returns a restore function that reverts package.json.
*
- * Workspace-only `@executor-js/*` peer deps (e.g. `@executor-js/api`,
- * `@executor-js/react`) that aren't in `publishable` are stripped from
+ * Workspace-only `@executor-js/*` peer deps (e.g. `@executor-js/react`) that
+ * aren't in `publishable` are stripped from
* `peerDependencies` (and `peerDependenciesMeta`) entirely — they don't
* exist on npm, so leaving them in the packed manifest would emit
* install-time warnings for unresolvable packages.
@@ -126,7 +127,7 @@ const applyWorkspaceVersions = async (
mutated = true;
} else if (isInternalScope(key) && !publishable.has(key)) {
// Workspace-only `@executor-js/*` regular dep that we don't
- // publish (e.g. `@executor-js/api`). Strip it: it's not in the
+ // publish (e.g. `@executor-js/react`). Strip it: it's not in the
// shipped runtime entries (those imports live in
// `src/api/*` / `src/react/*` which don't make it into the
// packed dist), and leaving it in would 404 at install time.
@@ -141,8 +142,8 @@ const applyWorkspaceVersions = async (
/**
* Peer-deps variant of `renameDepBlock`: resolve workspace specifiers for
* publishable peers, but DROP non-publishable `@executor-js/*` peers.
- * They reference workspace-only packages (`@executor-js/api`,
- * `@executor-js/react`) that don't exist on npm, so leaving them in
+ * They reference workspace-only packages (`@executor-js/react`) that don't
+ * exist on npm, so leaving them in
* the packed manifest emits install-time warnings for unresolvable
* packages. Non-`@executor-js` peers (`react`, `@tanstack/*`,
* `@effect-atom/*`, etc.) are real npm packages and pass through
diff --git a/scripts/smoke-test-packed.ts b/scripts/smoke-test-packed.ts
index 4273e2c4e3..090d969a3a 100644
--- a/scripts/smoke-test-packed.ts
+++ b/scripts/smoke-test-packed.ts
@@ -37,6 +37,7 @@ const PUBLIC_PACKAGE_DIRS = [
"packages/kernel/core",
"packages/kernel/runtime-quickjs",
"packages/core/sdk",
+ "packages/core/api",
"packages/core/config",
"packages/core/execution",
"packages/core/cli",
@@ -52,7 +53,10 @@ const PUBLIC_PACKAGE_DIRS = [
type PackageJson = {
name: string;
version: string;
+ catalog?: Record;
+ dependencies?: Record;
exports?: Record;
+ peerDependencies?: Record;
};
const readPackageJson = async (pkgDir: string): Promise => {
@@ -109,6 +113,7 @@ type Tarballs = ReadonlyMap;
const smokeTestPackage = async (
pkgDir: string,
tarballs: Tarballs,
+ catalog: Readonly>,
failures: SmokeFailure[],
): Promise => {
const pkg = await readPackageJson(pkgDir);
@@ -135,7 +140,17 @@ const smokeTestPackage = async (
version: "0.0.0",
private: true,
type: "module",
- dependencies: { [pkg.name]: `file:${tarballPath}` },
+ dependencies: {
+ [pkg.name]: `file:${tarballPath}`,
+ ...(pkg.name === "@executor-js/api" && pkg.peerDependencies?.effect
+ ? {
+ effect:
+ pkg.peerDependencies.effect === "catalog:"
+ ? catalog.effect
+ : pkg.peerDependencies.effect,
+ }
+ : {}),
+ },
overrides,
};
await writeFile(join(tmp, "package.json"), `${JSON.stringify(fixture, null, 2)}\n`);
@@ -156,6 +171,25 @@ const smokeTestPackage = async (
// Read the installed manifest — that's the real published view
// (publishConfig.exports applied, workspace specifiers resolved).
const installedPkg = await readPackageJson(join(tmp, "node_modules", ...pkg.name.split("/")));
+ if (pkg.name === "@executor-js/api") {
+ if (installedPkg.dependencies?.["@executor-js/host-mcp"] !== undefined) {
+ failures.push({
+ pkg: pkg.name,
+ subpath: "",
+ reason: "published client manifest retains private @executor-js/host-mcp",
+ });
+ return;
+ }
+ const publishedSubpaths = Object.keys(installedPkg.exports ?? {});
+ if (publishedSubpaths.length !== 1 || publishedSubpaths[0] !== "./client") {
+ failures.push({
+ pkg: pkg.name,
+ subpath: "",
+ reason: `published API surface is ${publishedSubpaths.join(", ") || "empty"}`,
+ });
+ return;
+ }
+ }
const subpaths = subpathsToTest(installedPkg);
if (subpaths.length === 0) {
failures.push({
@@ -211,7 +245,141 @@ const smokeTestPackage = async (
}
};
+/**
+ * Exercise the API tarball as an external consumer actually receives it: the
+ * API itself is the only local tarball, and npm resolves every encoded runtime
+ * dependency from the registry. The batch smoke above intentionally replaces
+ * the full workspace graph with local tarballs; that is useful, but it can hide
+ * a client import that relies on an SDK export which has not been published.
+ */
+const smokeTestApiCleanDependencyGraph = async (
+ tarballs: Tarballs,
+ catalog: Readonly>,
+ failures: SmokeFailure[],
+): Promise => {
+ const packageName = "@executor-js/api";
+ const tarballPath = tarballs.get(packageName);
+ if (!tarballPath) {
+ failures.push({ pkg: packageName, subpath: "", reason: "no tarball produced" });
+ return;
+ }
+
+ const tmp = await mkdtemp(join(tmpdir(), "executor-api-clean-smoke-"));
+ try {
+ const fixture = {
+ name: "executor-api-clean-smoke-fixture",
+ version: "0.0.0",
+ private: true,
+ type: "module",
+ dependencies: {
+ [packageName]: `file:${tarballPath}`,
+ effect: catalog.effect,
+ typescript: catalog.typescript,
+ },
+ };
+ await writeFile(join(tmp, "package.json"), `${JSON.stringify(fixture, null, 2)}\n`);
+ await writeFile(
+ join(tmp, "consumer.ts"),
+ [
+ 'import { makeExecutorApiClient } from "@executor-js/api/client";',
+ 'import type { ExecutorApiClient } from "@executor-js/api/client";',
+ "void makeExecutorApiClient;",
+ "const client: ExecutorApiClient | undefined = undefined;",
+ "void client;",
+ "",
+ ].join("\n"),
+ );
+
+ const install = await $`npm install --no-audit --no-fund --legacy-peer-deps`
+ .cwd(tmp)
+ .quiet()
+ .nothrow();
+ if (install.exitCode !== 0) {
+ failures.push({
+ pkg: packageName,
+ subpath: "",
+ reason: install.stderr.toString().trim().split("\n").slice(-3).join("\n"),
+ });
+ return;
+ }
+
+ const installedApi = await readPackageJson(
+ join(tmp, "node_modules", ...packageName.split("/")),
+ );
+ const sdkVersion = installedApi.dependencies?.["@executor-js/sdk"];
+ if (!sdkVersion) {
+ failures.push({
+ pkg: packageName,
+ subpath: "",
+ reason: "packed manifest has no encoded @executor-js/sdk dependency",
+ });
+ return;
+ }
+
+ const lock = JSON.parse(await readFile(join(tmp, "package-lock.json"), "utf8")) as {
+ readonly packages?: Readonly>;
+ };
+ const sdkResolution = lock.packages?.["node_modules/@executor-js/sdk"]?.resolved;
+ if (!sdkResolution?.startsWith("https://registry.npmjs.org/")) {
+ failures.push({
+ pkg: packageName,
+ subpath: "",
+ reason: `SDK did not resolve from the public registry: ${sdkResolution ?? "missing"}`,
+ });
+ return;
+ }
+
+ const installedApiRoot = join(tmp, "node_modules", ...packageName.split("/"));
+ const clientSource = await readFile(join(installedApiRoot, "dist", "client.js"), "utf8");
+ if (/\bfrom\s+["']@executor-js\//u.test(clientSource)) {
+ failures.push({
+ pkg: packageName,
+ subpath: "",
+ reason: "runtime client still imports a workspace package instead of its bundled schemas",
+ });
+ return;
+ }
+
+ const probe =
+ await $`node --input-type=module --eval ${`await import(${JSON.stringify(`${packageName}/client`)});`}`
+ .cwd(tmp)
+ .quiet()
+ .nothrow();
+ if (probe.exitCode !== 0) {
+ const stderr = probe.stderr.toString();
+ const missingExportMatch = stderr.match(MISSING_EXPORT_RE);
+ const reason = missingExportMatch
+ ? `published '${missingExportMatch[1]}' does not export '${missingExportMatch[2]}'`
+ : firstMeaningfulLine(stderr);
+ failures.push({ pkg: packageName, subpath: "", reason });
+ console.log(` FAIL ${packageName}/client — ${reason}`);
+ return;
+ }
+
+ const typecheck =
+ await $`npm exec --no -- tsc --noEmit --module ESNext --moduleResolution Bundler --target ESNext --skipLibCheck consumer.ts`
+ .cwd(tmp)
+ .quiet()
+ .nothrow();
+ if (typecheck.exitCode !== 0) {
+ failures.push({
+ pkg: packageName,
+ subpath: "",
+ reason: firstMeaningfulLine(
+ `${typecheck.stdout.toString()}\n${typecheck.stderr.toString()}`,
+ ),
+ });
+ return;
+ }
+
+ console.log(` ok ${packageName}/client (clean registry graph + types, SDK ${sdkVersion})`);
+ } finally {
+ await rm(tmp, { recursive: true, force: true });
+ }
+};
+
const main = async () => {
+ const rootPackage = await readPackageJson(repoRoot);
console.log("[smoke] packing public packages via publish-packages.ts --dry-run");
await $`bun run scripts/publish-packages.ts --dry-run`.cwd(repoRoot);
@@ -233,8 +401,10 @@ const main = async () => {
}
const pkg = await readPackageJson(pkgDir);
console.log(`[smoke] ${pkg.name}`);
- await smokeTestPackage(pkgDir, tarballs, failures);
+ await smokeTestPackage(pkgDir, tarballs, rootPackage.catalog ?? {}, failures);
}
+ console.log("[smoke] @executor-js/api clean dependency graph");
+ await smokeTestApiCleanDependencyGraph(tarballs, rootPackage.catalog ?? {}, failures);
if (failures.length === 0) {
console.log("[smoke] all packages OK");