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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/cloud/src/api/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{
name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null,
avatarUrl: session.avatarUrl ?? null,
});
const { executor, engine } = yield* makeExecutionStack(auth.accountId, org.id, org.name);
const { executor, engine } = yield* makeExecutionStack({
userId: auth.accountId,
organizationId: org.id,
organizationName: org.name,
});
return yield* httpEffect.pipe(
Effect.provideService(AuthContext, auth),
Effect.provideService(ExecutorService, executor),
Expand Down
10 changes: 5 additions & 5 deletions apps/cloud/src/mcp-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,11 @@ export class McpSessionDO extends DurableObject {
) {
const self = this;
return Effect.gen(function* () {
const { executor, engine } = yield* makeExecutionStack(
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
);
const { executor, engine } = yield* makeExecutionStack({
userId: sessionMeta.userId,
organizationId: sessionMeta.organizationId,
organizationName: sessionMeta.organizationName,
});
// Build the description here so the postgres query it runs
// (`executor.sources.list`) lands as a child of
// `McpSessionDO.createRuntime`. host-mcp would otherwise call
Expand Down
18 changes: 6 additions & 12 deletions apps/cloud/src/services/__test-harness__/api-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
import { createExecutionEngine } from "@executor-js/execution";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
import {
Scope,
collectSchemas,
createExecutor,
} from "@executor-js/sdk";
Expand All @@ -51,6 +50,7 @@ import {
} from "../../api/protected-layers";
import { DbService } from "../db";
import { orgScopeId, userOrgScopeId } from "../ids";
import { buildGlobalScopeStack } from "../scope-stack";

export const TEST_BASE_URL = "http://test.local";
export const TEST_ORG_HEADER = "x-test-org-id";
Expand Down Expand Up @@ -82,18 +82,12 @@ const createTestScopedExecutor = (
const schema = collectSchemas(plugins);
const adapter = makePostgresAdapter({ db, schema });
const blobs = makePostgresBlobStore({ db });
const orgScope = new Scope({
id: orgScopeId(orgId),
name: orgName,
createdAt: new Date(),
});
const userOrgScope = new Scope({
id: userOrgScopeId(userId, orgId),
name: `Personal · ${orgName}`,
createdAt: new Date(),
});
return yield* createExecutor({
scopes: [userOrgScope, orgScope],
scopes: buildGlobalScopeStack({
userId,
organizationId: orgId,
organizationName: orgName,
}),
adapter,
blobs,
plugins,
Expand Down
34 changes: 20 additions & 14 deletions apps/cloud/src/services/execution-stack.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ---------------------------------------------------------------------------
// Shared execution stack — the wiring that turns an organization into a
// Shared execution stack — the wiring that turns a request context into a
// runnable executor + engine. Used by the protected HTTP API (per-request)
// and the MCP session DO (per-session) so changes to the stack flow to both.
// and the MCP session DO (per-session) so changes flow to both.
// ---------------------------------------------------------------------------

import { env } from "cloudflare:workers";
Expand All @@ -12,23 +12,29 @@ import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";

import { withExecutionUsageTracking } from "../api/execution-usage";
import { AutumnService } from "./autumn";
import { createScopedExecutor } from "./executor";
import {
createGlobalExecutor,
createWorkspaceExecutor,
} from "./executor";
import type {
GlobalContext,
WorkspaceContext,
} from "./scope-stack";

export const makeExecutionStack = (
userId: string,
organizationId: string,
organizationName: string,
) =>
const buildExecutor = (ctx: GlobalContext | WorkspaceContext) =>
"workspaceId" in ctx
? createWorkspaceExecutor(ctx)
: createGlobalExecutor(ctx);

export const makeExecutionStack = (ctx: GlobalContext | WorkspaceContext) =>
Effect.gen(function* () {
const executor = yield* createScopedExecutor(
userId,
organizationId,
organizationName,
).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor"));
const executor = yield* buildExecutor(ctx).pipe(
Effect.withSpan("McpSessionDO.createExecutor"),
);
const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
const autumn = yield* AutumnService;
const engine = withExecutionUsageTracking(
organizationId,
ctx.organizationId,
createExecutionEngine({ executor, codeExecutor }),
(orgId) => Effect.runFork(autumn.trackExecution(orgId)),
);
Expand Down
67 changes: 27 additions & 40 deletions apps/cloud/src/services/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
// Cloud executor — stateless, per-request, new SDK shape
// ---------------------------------------------------------------------------
//
// Each invocation of `createScopedExecutor` runs inside a request-scoped
// Effect and yields a fresh executor bound to the current DbService's
// per-request postgres.js client. Cloudflare Workers + Hyperdrive demand
// fresh connections per request, so "build once" means "once per request"
// here.
// Each invocation of `createGlobalExecutor` / `createWorkspaceExecutor` runs
// inside a request-scoped Effect and yields a fresh executor bound to the
// current DbService's per-request postgres.js client. Cloudflare Workers +
// Hyperdrive demand fresh connections per request, so "build once" means
// "once per request" here.

import { Effect } from "effect";

import {
Scope,
collectSchemas,
createExecutor,
type Scope,
} from "@executor-js/sdk";
import {
makePostgresAdapter,
Expand All @@ -23,7 +23,12 @@ import {
import { env } from "cloudflare:workers";
import executorConfig from "../../executor.config";
import { DbService } from "./db";
import { orgScopeId, userOrgScopeId } from "./ids";
import {
buildGlobalScopeStack,
buildWorkspaceScopeStack,
type GlobalContext,
type WorkspaceContext,
} from "./scope-stack";

// ---------------------------------------------------------------------------
// Plugin list lives in `executor.config.ts` — that file is the single
Expand All @@ -43,53 +48,35 @@ const orgPlugins = (): CloudPlugins =>
});

// ---------------------------------------------------------------------------
// Create a fresh executor for a (user, org) pair (stateless, per-request).
// Create a fresh executor for a request context (stateless, per-request).
//
// Scope stack is `[userOrgScope, orgScope]` — innermost first. Scope ids are
// deterministic and prefixed (`org_<orgId>`, `user_org_<userId>_<orgId>`) so
// the same WorkOS user in a different org gets a distinct scope row, and
// future workspace scopes can slot in between without colliding with org or
// user-org rows.
// Scope stacks are built innermost-first by `./scope-stack`:
// global -> [userOrgScope, orgScope]
// workspace -> [userWorkspaceScope, workspaceScope, userOrgScope, orgScope]
//
// OAuth tokens land at `ctx.scopes[0]` (the user-org scope) by default, so
// a member's access/refresh tokens can't leak to other members via
// `secrets.list`, while source rows and org-wide credentials live on the
// outer scope.
// OAuth tokens land at `ctx.scopes[0]` (the most-personal scope) by default,
// so per-user credentials can't leak across users in the same workspace/org.
// Source rows and shared credentials live on the outer scopes.
// ---------------------------------------------------------------------------

export const createScopedExecutor = (
userId: string,
organizationId: string,
organizationName: string,
) =>
const buildExecutor = (scopes: ReadonlyArray<Scope>) =>
Effect.gen(function* () {
const { db } = yield* DbService;

const plugins = orgPlugins();
const schema = collectSchemas(plugins);
const adapter = makePostgresAdapter({ db, schema });
const blobs = makePostgresBlobStore({ db });

const orgScope = new Scope({
id: orgScopeId(organizationId),
name: organizationName,
createdAt: new Date(),
});
const userOrgScope = new Scope({
id: userOrgScopeId(userId, organizationId),
name: `Personal · ${organizationName}`,
createdAt: new Date(),
});

// The executor surface returns raw `StorageFailure`; translation to
// the opaque `InternalError({ traceId })` happens at the HTTP edge
// via `withCapture` (see `api/protected-layers.ts`). That's
// where `ErrorCaptureLive` (Sentry) gets wired in.
return yield* createExecutor({
scopes: [userOrgScope, orgScope],
scopes,
adapter,
blobs,
plugins,
onElicitation: "accept-all",
});
});

export const createGlobalExecutor = (ctx: GlobalContext) =>
buildExecutor(buildGlobalScopeStack(ctx));

export const createWorkspaceExecutor = (ctx: WorkspaceContext) =>
buildExecutor(buildWorkspaceScopeStack(ctx));
65 changes: 65 additions & 0 deletions apps/cloud/src/services/scope-stack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from "@effect/vitest";

import {
activeWriteScopeId,
buildGlobalScopeStack,
buildWorkspaceScopeStack,
} from "./scope-stack";

describe("buildGlobalScopeStack", () => {
it("emits [userOrgScope, orgScope] in inner-first order", () => {
const stack = buildGlobalScopeStack({
userId: "u1",
organizationId: "o1",
organizationName: "Acme",
});
expect(stack.length).toBe(2);
expect(stack[0]!.id.toString()).toBe("user_org_u1_o1");
expect(stack[0]!.name).toBe("Me / Acme");
expect(stack[1]!.id.toString()).toBe("org_o1");
expect(stack[1]!.name).toBe("Acme Global");
});
});

describe("buildWorkspaceScopeStack", () => {
it("emits [userWorkspace, workspace, userOrg, org] in inner-first order", () => {
const stack = buildWorkspaceScopeStack({
userId: "u1",
organizationId: "o1",
organizationName: "Acme",
workspaceId: "w1",
workspaceName: "Billing API",
});
expect(stack.length).toBe(4);
expect(stack[0]!.id.toString()).toBe("user_workspace_u1_w1");
expect(stack[0]!.name).toBe("Me / Billing API");
expect(stack[1]!.id.toString()).toBe("workspace_w1");
expect(stack[1]!.name).toBe("Billing API");
expect(stack[2]!.id.toString()).toBe("user_org_u1_o1");
expect(stack[2]!.name).toBe("Me / Acme");
expect(stack[3]!.id.toString()).toBe("org_o1");
expect(stack[3]!.name).toBe("Acme Global");
});
});

describe("activeWriteScopeId", () => {
it("returns the org scope id in global context", () => {
const id = activeWriteScopeId({
userId: "u1",
organizationId: "o1",
organizationName: "Acme",
});
expect(id.toString()).toBe("org_o1");
});

it("returns the workspace scope id in workspace context", () => {
const id = activeWriteScopeId({
userId: "u1",
organizationId: "o1",
organizationName: "Acme",
workspaceId: "w1",
workspaceName: "Billing API",
});
expect(id.toString()).toBe("workspace_w1");
});
});
96 changes: 96 additions & 0 deletions apps/cloud/src/services/scope-stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// ---------------------------------------------------------------------------
// Scope stack builders
// ---------------------------------------------------------------------------
//
// Two flavors mirror the URL contexts the plan calls out:
//
// Global (`/:org`):
// [user_org_<userId>_<orgId>, org_<orgId>]
//
// Workspace (`/:org/:workspace`):
// [user_workspace_<userId>_<workspaceId>,
// workspace_<workspaceId>,
// user_org_<userId>_<orgId>,
// org_<orgId>]
//
// Innermost first — the executor walks the stack so user-level wins over
// org-level on read, and writes target whichever scope the caller names.
// `activeWriteScopeId` is the default scope a source-definition write should
// target unless the caller picks something else.

import { Scope } from "@executor-js/sdk";

import {
orgScopeId,
userOrgScopeId,
userWorkspaceScopeId,
workspaceScopeId,
} from "./ids";

export type GlobalContext = {
readonly userId: string;
readonly organizationId: string;
readonly organizationName: string;
};

export type WorkspaceContext = GlobalContext & {
readonly workspaceId: string;
readonly workspaceName: string;
};

const now = () => new Date();

const orgScope = (ctx: GlobalContext): Scope =>
new Scope({
id: orgScopeId(ctx.organizationId),
name: `${ctx.organizationName} Global`,
createdAt: now(),
});

const userOrgScope = (ctx: GlobalContext): Scope =>
new Scope({
id: userOrgScopeId(ctx.userId, ctx.organizationId),
name: `Me / ${ctx.organizationName}`,
createdAt: now(),
});

const workspaceScope = (ctx: WorkspaceContext): Scope =>
new Scope({
id: workspaceScopeId(ctx.workspaceId),
name: ctx.workspaceName,
createdAt: now(),
});

const userWorkspaceScope = (ctx: WorkspaceContext): Scope =>
new Scope({
id: userWorkspaceScopeId(ctx.userId, ctx.workspaceId),
name: `Me / ${ctx.workspaceName}`,
createdAt: now(),
});

export const buildGlobalScopeStack = (
ctx: GlobalContext,
): readonly [Scope, Scope] => [userOrgScope(ctx), orgScope(ctx)] as const;

export const buildWorkspaceScopeStack = (
ctx: WorkspaceContext,
): readonly [Scope, Scope, Scope, Scope] => [
userWorkspaceScope(ctx),
workspaceScope(ctx),
userOrgScope(ctx),
orgScope(ctx),
] as const;

/**
* Default scope for source-definition writes in the active context. `org` for
* global, `workspace` for workspace contexts. Callers MUST still pass an
* explicit target on the write — this is purely a UI default.
*/
export const activeWriteScopeId = (
ctx: GlobalContext | WorkspaceContext,
) => {
if ("workspaceId" in ctx) {
return workspaceScopeId(ctx.workspaceId);
}
return orgScopeId(ctx.organizationId);
};
Loading
Loading