History

feat: modularize v2 control plane and remove rpc legacy

Split control-plane and Convex handlers into domain-focused modules, keep v1-only transport routes, and remove obsolete rpc/state-convex scaffolding so local and remote share the same Effect HttpApi architecture.

Rhys Sullivan committed 8 months ago

Commit fc53fa81 parent 31348e650 files · +1379 −526Browse files

Unified · Split

50 files
  1. v2/PLAN.md +10 −11
  2. v2/apps/convex/README.md +6 −4
  3. v2/apps/convex/convex/_generated/api.d.ts +10 −2
  4. v2/apps/convex/convex/controlPlane.ts +12 −0
  5. v2/apps/convex/convex/control_plane/errors.ts +75 −0
  6. v2/apps/convex/convex/control_plane/http.ts +65 −0
  7. v2/apps/convex/convex/control_plane/service.ts +41 −0
  8. v2/apps/convex/convex/control_plane/sources.ts +140 −0
  9. v2/apps/convex/convex/executor.test.ts +61 −35
  10. v2/apps/convex/convex/executor.ts +2 −44
  11. v2/apps/convex/convex/http.ts +30 −6
  12. v2/apps/convex/convex/mcp.ts +6 −6
  13. v2/apps/convex/convex/rpc_exit.ts +0 −50
  14. v2/apps/convex/convex/run_executor.ts +27 −32
  15. v2/apps/convex/convex/runtimeCallbacks.ts +55 −84
  16. v2/apps/convex/convex/schema.ts +56 −1
  17. v2/apps/convex/package.json +2 −1
  18. v2/apps/pm/README.md +5 −2
  19. v2/apps/pm/package.json +2 −0
  20. v2/apps/pm/src/execute.e2e.test.ts +1 −1
  21. v2/apps/pm/src/http-server.ts +25 −4
  22. v2/apps/pm/src/main.ts +13 −1
  23. v2/apps/pm/src/run-executor.ts +11 −15
  24. v2/apps/pm/src/tool-call-handler.ts +21 −22
  25. v2/bun.lock +17 −17
  26. v2/packages/control-plane/package.json +6 −4
  27. v2/packages/control-plane/src/api.ts +26 −0
  28. v2/packages/control-plane/src/client.ts +159 −0
  29. v2/packages/control-plane/src/errors.ts +22 −0
  30. v2/packages/control-plane/src/http.ts +20 −0
  31. v2/packages/control-plane/src/index.ts +44 −0
  32. v2/packages/control-plane/src/service.ts +37 −0
  33. v2/packages/control-plane/src/sources/api.ts +51 −0
  34. v2/packages/control-plane/src/sources/http.ts +67 −0
  35. v2/packages/control-plane/src/sources/index.ts +16 −0
  36. v2/packages/control-plane/src/sources/service.ts +45 −0
  37. v2/packages/control-plane/tsconfig.json +4 −1
  38. v2/packages/engine/src/run-client.ts +17 −18
  39. v2/packages/rpc/README.md +0 −3
  40. v2/packages/rpc/package.json +0 −8
  41. v2/packages/rpc/src/index.ts +0 −1
  42. v2/packages/source-manager/package.json +2 −1
  43. v2/packages/source-manager/src/index.ts +12 −0
  44. v2/packages/source-manager/src/source-catalog.ts +158 −0
  45. v2/packages/state-convex/README.md +0 −8
  46. v2/packages/state-convex/src/convex-ids.ts +0 −25
  47. v2/packages/state-convex/src/index.ts +0 −26
  48. v2/packages/state-convex/src/profiles.ts +0 −33
  49. v2/packages/state-convex/src/schema.ts +0 −56
  50. v2/packages/state-convex/tsconfig.json +0 −4
v2/PLAN.md+10−11
@@ -64,7 +64,7 @@ Control is optional capability, not required for execute-only environments. · 63 unmodified lines
6464 How clients talk to executor.
6565
6666 - MCP server/gateway
67−- OpenAPI/RPC control API
67+- OpenAPI control API
6868 - AI SDK tool bridge
6969
7070 Transport does not own core execution semantics.
@@ -167,7 +167,7 @@ This is mostly transport/API behavior; core runtime pipeline stays the same. · 96 unmodified lines
167167
168168 ---
169169
170−## 8) MCP, RPC, and AI SDK strategy
170+## 8) MCP, OpenAPI, and AI SDK strategy
171171
172172 ### MCP
173173
@@ -179,7 +179,7 @@ Top-level model-facing tools stay minimal: · 5 unmodified lines
179179
180180 ### Control API
181181
182−Control is exposed through Executor OpenAPI/RPC contracts.
182+Control is exposed through Executor OpenAPI contracts.
183183
184184 - Host/app control calls should use control API directly.
185185 - Model control calls can use `tools.executor.*` inside `execute` code.
@@ -287,16 +287,15 @@ v2/ · 101 unmodified lines
287287 schema/
288288 state-contracts/
289289 state-local-file/
290− state-convex/
291290 domain/
292291 approvals/
293292 source-manager/
293+ control-plane/
294294 engine/
295295 mcp-gateway/
296296 oauth/
297297 secrets/
298298 sync/
299− rpc/
300299 sdk/
301300 ai-sdk-adapter/
302301 ```
@@ -313,15 +312,15 @@ Planned follow-up split (after behavior stabilizes): · 10 unmodified lines
313312 confect -> schema -> state-contracts
314313
315314 state-contracts -> state-local-file
316−state-contracts -> state-convex
317315
318316 schema + state-contracts + oauth + secrets -> domain
319317 domain -> approvals/source-manager/engine
318+source-manager + schema -> control-plane
320319 approvals/source-manager/engine -> mcp-gateway
321−domain/features -> rpc -> sdk -> ai-sdk-adapter
320+domain/features -> sdk -> ai-sdk-adapter
322321
323−apps/pm -> mcp-gateway + domain + state-local-file + rpc
324−apps/convex -> mcp-gateway + domain + state-convex + rpc
322+apps/pm -> mcp-gateway + domain + state-local-file + control-plane
323+apps/convex -> mcp-gateway + domain + control-plane
325324 apps/cli/web -> sdk (and/or ai-sdk bridge)
326325 ```
327326 Rules:
@@ -367,7 +366,7 @@ Rules: · 39 unmodified lines
367366 ### Phase 5: transport polish
368367
369368 - MCP surface stabilization
370−- OpenAPI/RPC control surface stabilization
369+- OpenAPI control surface stabilization
371370 - AI SDK bridge (`toAiSdkTools`) and docs/examples
372371
373372 ---
@@ -414,7 +413,7 @@ Still missing: · 40 unmodified lines
414413 - shared tool invocation service wired with persisted approval lifecycle in v2 path
415414 - cloudflare-worker-loader runtime adapter implementation in v2 runtime path
416415 - parity conformance tests for runtime/provider/pending-approval/cancel flows
417−- full rpc/sdk/ai-sdk adapter wiring and usage examples
416+- full openapi/sdk/ai-sdk adapter wiring and usage examples
418417
419418 ---
420419
v2/apps/convex/README.md+6−4
@@ -3,7 +3,9 @@ · 2 unmodified lines
33 Convex remote server app scaffold for Executor v2.
44
55 Current scaffold includes:
6− Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex`
7− MCP HTTP endpoint at `convex/http.ts` and `convex/mcp.ts`, wired through a run client
8− runtime callback endpoints at `POST /runtime/tool-call` and `POST /v1/runtime/tool-call`
9− action-level callback handler scaffold in `convex/runtimeCallbacks.ts`
6+ Convex schema wiring lives directly in `convex/schema.ts`
7+ MCP HTTP endpoint at `GET/POST/DELETE /v1/mcp` via `convex/http.ts` and `convex/mcp.ts`
8+ runtime callback endpoint at `POST /v1/runtime/tool-call`
9+ control-plane source endpoints at `GET/POST /v1/workspaces/:workspaceId/sources`
10+ control-plane source removal endpoint at `DELETE /v1/workspaces/:workspaceId/sources/:sourceId`
11+ generated OpenAPI spec endpoint at `GET /v1/openapi.json`
v2/apps/convex/convex/_generated/api.d.ts+10−2
@@ -8,10 +8,14 @@ · 7 unmodified lines
88 * @module
99 */
1010
11+import type * as controlPlane from "../controlPlane.js";
12+import type * as control_plane_errors from "../control_plane/errors.js";
13+import type * as control_plane_http from "../control_plane/http.js";
14+import type * as control_plane_service from "../control_plane/service.js";
15+import type * as control_plane_sources from "../control_plane/sources.js";
1116 import type * as executor from "../executor.js";
1217 import type * as http from "../http.js";
1318 import type * as mcp from "../mcp.js";
14−import type * as rpc_exit from "../rpc_exit.js";
1519 import type * as run_executor from "../run_executor.js";
1620 import type * as runtimeCallbacks from "../runtimeCallbacks.js";
1721 import type * as runtime_adapter from "../runtime_adapter.js";
@@ -23,10 +27,14 @@ import type { · 5 unmodified lines
2327 } from "convex/server";
2428
2529 declare const fullApi: ApiFromModules<{
30+ controlPlane: typeof controlPlane;
31+ "control_plane/errors": typeof control_plane_errors;
32+ "control_plane/http": typeof control_plane_http;
33+ "control_plane/service": typeof control_plane_service;
34+ "control_plane/sources": typeof control_plane_sources;
2635 executor: typeof executor;
2736 http: typeof http;
2837 mcp: typeof mcp;
29− rpc_exit: typeof rpc_exit;
3038 run_executor: typeof run_executor;
3139 runtimeCallbacks: typeof runtimeCallbacks;
3240 runtime_adapter: typeof runtime_adapter;
v2/apps/convex/convex/controlPlane.ts+12−0
@@ -0,0 +1,12 @@
1+import {
2+ listSources as listSourcesImpl,
3+ removeSource as removeSourceImpl,
4+ upsertSource as upsertSourceImpl,
5+} from "./control_plane/sources";
6+import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http";
7+
8+export const listSources = listSourcesImpl;
9+export const upsertSource = upsertSourceImpl;
10+export const removeSource = removeSourceImpl;
11+
12+export const controlPlaneHttpHandler = controlPlaneHttpHandlerImpl;
v2/apps/convex/convex/control_plane/errors.ts+75−0
@@ -0,0 +1,75 @@
1+import { SourceStoreError } from "@executor-v2/persistence-ports";
2+
3+type ControlPlaneErrorCode =
4+ | "bad_request"
5+ | "not_found"
6+ | "method_not_allowed"
7+ | "internal";
8+
9+type ControlPlaneErrorResponse = {
10+ ok: false;
11+ error: {
12+ code: ControlPlaneErrorCode;
13+ message: string;
14+ details: string | null;
15+ };
16+};
17+
18+const renderCause = (cause: unknown): string => String(cause);
19+
20+export const toSourceStoreError = (
21+ operation: string,
22+ cause: unknown,
23+): SourceStoreError =>
24+ new SourceStoreError({
25+ operation,
26+ filePath: "convex://control-plane",
27+ message: "Convex control plane call failed",
28+ reason: null,
29+ details: renderCause(cause),
30+ });
31+
32+const toErrorCode = (status: number): ControlPlaneErrorCode => {
33+ switch (status) {
34+ case 400:
35+ return "bad_request";
36+ case 404:
37+ return "not_found";
38+ case 405:
39+ return "method_not_allowed";
40+ default:
41+ return "internal";
42+ }
43+};
44+
45+export const controlPlaneErrorResponse = (
46+ status: number,
47+ message: string,
48+ details: string | null,
49+): Response =>
50+ Response.json(
51+ {
52+ ok: false,
53+ error: {
54+ code: toErrorCode(status),
55+ message,
56+ details,
57+ },
58+ } satisfies ControlPlaneErrorResponse,
59+ { status },
60+ );
61+
62+const isJsonResponse = (response: Response): boolean =>
63+ response.headers.get("content-type")?.toLowerCase().includes("application/json") ?? false;
64+
65+export const normalizeControlPlaneErrorResponse = (response: Response): Response => {
66+ if (response.ok || isJsonResponse(response)) {
67+ return response;
68+ }
69+
70+ return controlPlaneErrorResponse(
71+ response.status,
72+ "Control plane request failed",
73+ response.statusText || null,
74+ );
75+};
v2/apps/convex/convex/control_plane/http.ts+65−0
@@ -0,0 +1,65 @@
1+import {
2+ ControlPlaneService,
3+ controlPlaneOpenApiSpec,
4+ makeControlPlaneWebHandler,
5+} from "@executor-v2/control-plane";
6+import * as Effect from "effect/Effect";
7+import * as Layer from "effect/Layer";
8+
9+import { httpAction, type ActionCtx } from "../_generated/server";
10+import {
11+ controlPlaneErrorResponse,
12+ normalizeControlPlaneErrorResponse,
13+ toSourceStoreError,
14+} from "./errors";
15+import { makeConvexControlPlaneService } from "./service";
16+
17+const isOpenApiRequest = (request: Request): boolean =>
18+ new URL(request.url).pathname === "/v1/openapi.json";
19+
20+const handleControlPlaneRequest = (
21+ ctx: ActionCtx,
22+ request: Request,
23+): Effect.Effect<Response, never> =>
24+ Effect.scoped(
25+ Effect.gen(function* () {
26+ if (isOpenApiRequest(request)) {
27+ return Response.json(controlPlaneOpenApiSpec, { status: 200 });
28+ }
29+
30+ const webHandler = makeControlPlaneWebHandler(
31+ Layer.succeed(ControlPlaneService, makeConvexControlPlaneService(ctx)),
32+ );
33+
34+ yield* Effect.addFinalizer(() =>
35+ Effect.tryPromise({
36+ try: () => webHandler.dispose(),
37+ catch: (cause) => toSourceStoreError("controlPlane.dispose", cause),
38+ }).pipe(
39+ Effect.asVoid,
40+ Effect.catchAll(() => Effect.void),
41+ ),
42+ );
43+
44+ const response = yield* Effect.tryPromise({
45+ try: () => webHandler.handler(request),
46+ catch: (cause) => toSourceStoreError("controlPlane.http", cause),
47+ });
48+
49+ return normalizeControlPlaneErrorResponse(response);
50+ }),
51+ ).pipe(
52+ Effect.catchAll((error) =>
53+ Effect.succeed(
54+ controlPlaneErrorResponse(
55+ 500,
56+ "Control plane request failed",
57+ error.details,
58+ ),
59+ ),
60+ ),
61+ );
62+
63+export const controlPlaneHttpHandler = httpAction((ctx, request) =>
64+ Effect.runPromise(handleControlPlaneRequest(ctx, request)),
65+);
v2/apps/convex/convex/control_plane/service.ts+41−0
@@ -0,0 +1,41 @@
1+import {
2+ ControlPlaneService,
3+ type ControlPlaneServiceShape,
4+} from "@executor-v2/control-plane";
5+import * as Effect from "effect/Effect";
6+
7+import { api } from "../_generated/api";
8+import type { ActionCtx } from "../_generated/server";
9+import { toSourceStoreError } from "./errors";
10+
11+export const makeConvexControlPlaneService = (
12+ ctx: ActionCtx,
13+): ControlPlaneServiceShape =>
14+ ControlPlaneService.of({
15+ listSources: (workspaceId) =>
16+ Effect.tryPromise({
17+ try: () =>
18+ ctx.runQuery(api.controlPlane.listSources, {
19+ workspaceId,
20+ }),
21+ catch: (cause) => toSourceStoreError("controlPlane.listSources", cause),
22+ }),
23+ upsertSource: (input) =>
24+ Effect.tryPromise({
25+ try: () =>
26+ ctx.runMutation(api.controlPlane.upsertSource, {
27+ workspaceId: input.workspaceId,
28+ payload: input.payload,
29+ }),
30+ catch: (cause) => toSourceStoreError("controlPlane.upsertSource", cause),
31+ }),
32+ removeSource: (input) =>
33+ Effect.tryPromise({
34+ try: () =>
35+ ctx.runMutation(api.controlPlane.removeSource, {
36+ workspaceId: input.workspaceId,
37+ sourceId: input.sourceId,
38+ }),
39+ catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause),
40+ }),
41+ });
v2/apps/convex/convex/control_plane/sources.ts+140−0
@@ -0,0 +1,140 @@
1+import { type UpsertSourcePayload } from "@executor-v2/control-plane";
2+import { SourceSchema, type Source } from "@executor-v2/schema";
3+import { v } from "convex/values";
4+import * as Schema from "effect/Schema";
5+
6+import { mutation, query } from "../_generated/server";
7+
8+const decodeSource = Schema.decodeUnknownSync(SourceSchema);
9+
10+const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`;
11+
12+const sortSources = (sources: ReadonlyArray<Source>): Array<Source> =>
13+ [...sources].sort((left, right) => {
14+ const leftName = left.name.toLowerCase();
15+ const rightName = right.name.toLowerCase();
16+
17+ if (leftName === rightName) {
18+ return sourceStoreKey(left).localeCompare(sourceStoreKey(right));
19+ }
20+
21+ return leftName.localeCompare(rightName);
22+ });
23+
24+const toSource = (document: Record<string, unknown>): Source => {
25+ const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...source } = document;
26+ return decodeSource(source);
27+};
28+
29+const sourceKindValidator = v.union(
30+ v.literal("mcp"),
31+ v.literal("openapi"),
32+ v.literal("graphql"),
33+ v.literal("internal"),
34+);
35+
36+const sourceStatusValidator = v.union(
37+ v.literal("draft"),
38+ v.literal("probing"),
39+ v.literal("auth_required"),
40+ v.literal("connected"),
41+ v.literal("error"),
42+);
43+
44+export const listSources = query({
45+ args: {
46+ workspaceId: v.string(),
47+ },
48+ handler: async (ctx, args): Promise<Array<Source>> => {
49+ const documents = await ctx.db
50+ .query("sources")
51+ .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
52+ .collect();
53+
54+ return sortSources(
55+ documents.map((document) =>
56+ toSource(document as unknown as Record<string, unknown>),
57+ ),
58+ );
59+ },
60+});
61+
62+export const upsertSource = mutation({
63+ args: {
64+ workspaceId: v.string(),
65+ payload: v.object({
66+ id: v.optional(v.string()),
67+ name: v.string(),
68+ kind: sourceKindValidator,
69+ endpoint: v.string(),
70+ status: v.optional(sourceStatusValidator),
71+ enabled: v.optional(v.boolean()),
72+ configJson: v.optional(v.string()),
73+ sourceHash: v.optional(v.union(v.string(), v.null())),
74+ lastError: v.optional(v.union(v.string(), v.null())),
75+ }),
76+ },
77+ handler: async (ctx, args): Promise<Source> => {
78+ const now = Date.now();
79+ const sourceId = args.payload.id ?? `src_${crypto.randomUUID()}`;
80+
81+ const existing = await ctx.db
82+ .query("sources")
83+ .withIndex("by_domainId", (q) => q.eq("id", sourceId))
84+ .unique();
85+
86+ const existingInWorkspace =
87+ existing && existing.workspaceId === args.workspaceId ? existing : null;
88+
89+ const payload = args.payload as UpsertSourcePayload;
90+
91+ const source = decodeSource({
92+ id: sourceId,
93+ workspaceId: args.workspaceId,
94+ name: payload.name,
95+ kind: payload.kind,
96+ endpoint: payload.endpoint,
97+ status: payload.status ?? "draft",
98+ enabled: payload.enabled ?? true,
99+ configJson: payload.configJson ?? "{}",
100+ sourceHash: payload.sourceHash ?? null,
101+ lastError: payload.lastError ?? null,
102+ createdAt: existingInWorkspace?.createdAt ?? now,
103+ updatedAt: now,
104+ });
105+
106+ if (existingInWorkspace) {
107+ await ctx.db.patch(existingInWorkspace._id, source);
108+ } else {
109+ await ctx.db.insert("sources", source);
110+ }
111+
112+ return source;
113+ },
114+});
115+
116+export const removeSource = mutation({
117+ args: {
118+ workspaceId: v.string(),
119+ sourceId: v.string(),
120+ },
121+ handler: async (
122+ ctx,
123+ args,
124+ ): Promise<{
125+ removed: boolean;
126+ }> => {
127+ const existing = await ctx.db
128+ .query("sources")
129+ .withIndex("by_domainId", (q) => q.eq("id", args.sourceId))
130+ .unique();
131+
132+ if (!existing || existing.workspaceId !== args.workspaceId) {
133+ return { removed: false };
134+ }
135+
136+ await ctx.db.delete(existing._id);
137+
138+ return { removed: true };
139+ },
140+});
v2/apps/convex/convex/executor.test.ts+61−35
@@ -3,7 +3,7 @@ import { convexTest } from "convex-test"; · 2 unmodified lines
33 import * as Effect from "effect/Effect";
44
55 import { api } from "./_generated/api";
6−import { unwrapRpcSuccess } from "./rpc_exit";
6+import { executeRunImpl } from "./executor";
77 import schema from "./schema";
88
99 const setup = () =>
@@ -12,54 +12,80 @@ const setup = () => · 2 unmodified lines
1212 "./mcp.ts": () => import("./mcp"),
1313 "./executor.ts": () => import("./executor"),
1414 "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"),
15+ "./controlPlane.ts": () => import("./controlPlane"),
1516 "./_generated/api.js": () => import("./_generated/api.js"),
1617 });
1718
18−describe("Convex executor actions", () => {
19− it.effect("executes code via executeRun action", () =>
19+describe("Convex executor and control-plane", () => {
20+ it.effect("executes code via executeRunImpl", () =>
2021 Effect.gen(function* () {
21− const t = setup();
22−
23− const result = unwrapRpcSuccess<{
24− status: string;
25− result?: unknown;
26− }>(
27− yield* Effect.tryPromise(() =>
28− t.action(api.executor.executeRun, {
29− code: "return 6 * 7;",
30− }),
31− ),
32− "executor.executeRun",
33− );
22+ const result = yield* executeRunImpl({
23+ code: "return 6 * 7;",
24+ });
3425
3526 expect(result.status).toBe("completed");
3627 expect(result.result).toBe(42);
3728 }),
3829 );
3930
40− it.effect("returns failed callback result from runtime callback action", () =>
31+ it.effect("upserts, lists, and removes sources", () =>
4132 Effect.gen(function* () {
4233 const t = setup();
4334
44− const result = unwrapRpcSuccess<{
45− ok: boolean;
46− kind?: string;
47− error?: string;
48− }>(
49− yield* Effect.tryPromise(() =>
50− t.action(api.runtimeCallbacks.handleToolCall, {
51− runId: "run_1",
52− callId: "call_1",
53− toolPath: "tools.example.lookup",
54− input: { query: "ping" },
55− }),
56− ),
57− "runtimeCallbacks.handleToolCall",
58− );
35+ const added = (yield* Effect.tryPromise(() =>
36+ t.mutation(api.controlPlane.upsertSource, {
37+ workspaceId: "ws_1",
38+ payload: {
39+ id: "src_1",
40+ name: "Weather",
41+ kind: "openapi",
42+ endpoint: "https://example.com/openapi.json",
43+ enabled: true,
44+ configJson: "{}",
45+ status: "draft",
46+ sourceHash: null,
47+ lastError: null,
48+ },
49+ }),
50+ )) as {
51+ id: string;
52+ workspaceId: string;
53+ name: string;
54+ };
55+
56+ expect(added.id).toBe("src_1");
57+ expect(added.workspaceId).toBe("ws_1");
58+ expect(added.name).toBe("Weather");
59+
60+ const listed = (yield* Effect.tryPromise(() =>
61+ t.query(api.controlPlane.listSources, {
62+ workspaceId: "ws_1",
63+ }),
64+ )) as Array<{
65+ id: string;
66+ }>;
67+
68+ expect(listed).toHaveLength(1);
69+ expect(listed[0]?.id).toBe("src_1");
70+
71+ const removed = (yield* Effect.tryPromise(() =>
72+ t.mutation(api.controlPlane.removeSource, {
73+ workspaceId: "ws_1",
74+ sourceId: "src_1",
75+ }),
76+ )) as {
77+ removed: boolean;
78+ };
79+
80+ expect(removed.removed).toBe(true);
81+
82+ const listedAfterRemove = (yield* Effect.tryPromise(() =>
83+ t.query(api.controlPlane.listSources, {
84+ workspaceId: "ws_1",
85+ }),
86+ )) as Array<unknown>;
5987
60− expect(result.ok).toBe(false);
61− expect(result.kind).toBe("failed");
62− expect(result.error).toContain("tools.example.lookup");
88+ expect(listedAfterRemove).toHaveLength(0);
6389 }),
6490 );
6591 });
v2/apps/convex/convex/executor.ts+2−44
@@ -1,9 +1,6 @@
1−import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc";
2−import { executorConfectSchema } from "@executor-v2/persistence-convex";
3−import type { ExecuteRunInput } from "@executor-v2/sdk";
1+import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
42 import * as Effect from "effect/Effect";
53 import * as Layer from "effect/Layer";
6−import * as Schema from "effect/Schema";
74
85 import {
96 ConvexRunExecutor,
@@ -11,24 +8,6 @@ import { · 1 unmodified line
118 ConvexToolProviderRegistryLive,
129 } from "./run_executor";
1310
14−const executeRunResultSchema = Schema.Struct({
15− runId: Schema.String,
16− status: Schema.Union(
17− Schema.Literal("completed"),
18− Schema.Literal("failed"),
19− Schema.Literal("timed_out"),
20− Schema.Literal("denied"),
21− ),
22− result: Schema.optional(Schema.Unknown),
23− error: Schema.optional(Schema.String),
24− exitCode: Schema.optional(Schema.Number),
25− durationMs: Schema.optional(Schema.Number),
26−});
27−
28−const factory = createRpcFactory({
29− schema: executorConfectSchema,
30−});
31−
3211 const ConvexExecuteDependenciesLive = Layer.merge(
3312 ConvexRunExecutorLive,
3413 ConvexToolProviderRegistryLive,
@@ -36,29 +15,8 @@ const ConvexExecuteDependenciesLive = Layer.merge( · 1 unmodified line
3615
3716 export const executeRunImpl = (
3817 input: ExecuteRunInput,
39−): Effect.Effect<typeof executeRunResultSchema.Type> =>
18+): Effect.Effect<ExecuteRunResult> =>
4019 Effect.gen(function* () {
4120 const runExecutor = yield* ConvexRunExecutor;
4221 return yield* runExecutor.executeRun(input);
4322 }).pipe(Effect.provide(ConvexExecuteDependenciesLive));
44−
45−const executeRunEndpoint = factory.action({
46− payload: {
47− code: Schema.String,
48− timeoutMs: Schema.optional(Schema.Number),
49− },
50− success: executeRunResultSchema,
51−});
52−
53−executeRunEndpoint.implement((payload) =>
54− executeRunImpl({
55− code: payload.code,
56− timeoutMs: payload.timeoutMs,
57− }),
58−);
59−
60−const executorRpc = makeRpcModule({
61− executeRun: executeRunEndpoint,
62−});
63−
64−export const executeRun = executorRpc.handlers.executeRun;
v2/apps/convex/convex/http.ts+30−6
@@ -1,24 +1,48 @@
11 import { httpRouter } from "convex/server";
2+
3+import { controlPlaneHttpHandler } from "./controlPlane";
24 import { mcpHandler } from "./mcp";
35 import { handleToolCallHttp } from "./runtimeCallbacks";
46
57 const http = httpRouter();
68
7−http.route({ path: "/mcp", method: "POST", handler: mcpHandler });
8−http.route({ path: "/mcp", method: "GET", handler: mcpHandler });
9−http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler });
109 http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });
1110 http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });
1211 http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });
1312 http.route({
14− path: "/runtime/tool-call",
13+ path: "/v1/runtime/tool-call",
1514 method: "POST",
1615 handler: handleToolCallHttp,
1716 });
1817 http.route({
19− path: "/v1/runtime/tool-call",
18+ pathPrefix: "/v1/",
19+ method: "GET",
20+ handler: controlPlaneHttpHandler,
21+});
22+http.route({
23+ pathPrefix: "/v1/",
2024 method: "POST",
21− handler: handleToolCallHttp,
25+ handler: controlPlaneHttpHandler,
26+});
27+http.route({
28+ pathPrefix: "/v1/",
29+ method: "PUT",
30+ handler: controlPlaneHttpHandler,
31+});
32+http.route({
33+ pathPrefix: "/v1/",
34+ method: "PATCH",
35+ handler: controlPlaneHttpHandler,
36+});
37+http.route({
38+ pathPrefix: "/v1/",
39+ method: "DELETE",
40+ handler: controlPlaneHttpHandler,
41+});
42+http.route({
43+ pathPrefix: "/v1/",
44+ method: "OPTIONS",
45+ handler: controlPlaneHttpHandler,
2246 });
2347
2448 export default http;
v2/apps/convex/convex/mcp.ts+6−6
@@ -1,13 +1,13 @@
11 import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
22 import { createExecutorRunClient } from "@executor-v2/sdk";
3−import { api } from "./_generated/api";
3+import * as Effect from "effect/Effect";
4+
45 import { httpAction } from "./_generated/server";
5−import { unwrapRpcSuccess } from "./rpc_exit";
6−export const mcpHandler = httpAction(async (ctx, request) => {
6+import { executeRunImpl } from "./executor";
7+
8+export const mcpHandler = httpAction(async (_ctx, request) => {
79 const runClient = createExecutorRunClient((input) =>
8− ctx
9− .runAction(api.executor.executeRun, input)
10− .then((result) => unwrapRpcSuccess(result, "executor.executeRun")),
10+ Effect.runPromise(executeRunImpl(input)),
1111 );
1212
1313 return handleMcpHttpRequest(request, {
v2/apps/convex/convex/rpc_exit.ts+0−50
@@ -1,50 +0,0 @@
1−type RpcExitSuccess<T> = {
2− _tag: "Success";
3− value: T;
4−};
5−
6−type RpcExitFailure = {
7− _tag: "Failure";
8− cause: unknown;
9−};
10−
11−const isRpcExitSuccess = <T>(value: unknown): value is RpcExitSuccess<T> =>
12− typeof value === "object" &&
13− value !== null &&
14− "_tag" in value &&
15− value._tag === "Success" &&
16− "value" in value;
17−
18−const isRpcExitFailure = (value: unknown): value is RpcExitFailure =>
19− typeof value === "object" &&
20− value !== null &&
21− "_tag" in value &&
22− value._tag === "Failure";
23−
24−const renderUnknown = (value: unknown): string => {
25− if (value instanceof Error) {
26− return value.message;
27− }
28−
29− try {
30− return JSON.stringify(value);
31− } catch {
32− return String(value);
33− }
34−};
35−
36−export const unwrapRpcSuccess = <T>(value: unknown, endpoint: string): T => {
37− if (isRpcExitSuccess<T>(value)) {
38− return value.value;
39− }
40−
41− if (isRpcExitFailure(value)) {
42− throw new Error(
43− `RPC endpoint '${endpoint}' returned failure exit: ${renderUnknown(value.cause)}`,
44− );
45− }
46−
47− throw new Error(
48− `RPC endpoint '${endpoint}' returned invalid exit payload: ${renderUnknown(value)}`,
49− );
50−};
v2/apps/convex/convex/run_executor.ts+27−32
@@ -1,14 +1,14 @@
11 import {
2+ makeLocalInProcessRuntimeAdapter,
23 makeToolProviderRegistry,
34 ToolProviderRegistryService,
4−} from "@executor-v2/engine/tool-providers";
5+ type RuntimeExecuteError,
6+} from "@executor-v2/engine";
57 import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
68 import * as Context from "effect/Context";
79 import * as Effect from "effect/Effect";
810 import * as Layer from "effect/Layer";
911
10−import { makeLocalInProcessRuntimeAdapter } from "./runtime_adapter";
11−
1212 export type ConvexRunExecutorService = {
1313 executeRun: (
1414 input: ExecuteRunInput,
@@ -21,21 +21,16 @@ export class ConvexRunExecutor extends Context.Tag( · 6 unmodified lines
2121
2222 const runtimeAdapter = makeLocalInProcessRuntimeAdapter();
2323
24−const errorToText = (error: unknown): string => {
25− if (error instanceof Error) {
26− return error.message;
27− }
28−
29− if (
30− typeof error === "object" &&
31− error !== null &&
32− "message" in error &&
33− typeof error.message === "string"
34− ) {
35− return error.message;
24+const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => {
25+ switch (error._tag) {
26+ case "RuntimeAdapterError":
27+ case "LocalCodeRunnerError":
28+ case "DenoSubprocessRunnerError":
29+ case "ToolProviderError":
30+ return error.details ? `${error.message}: ${error.details}` : error.message;
31+ case "ToolProviderRegistryError":
32+ return error.message;
3633 }
37−
38− return String(error);
3934 };
4035
4136 const executeRun = Effect.fn(
@@ -59,21 +54,21 @@ const executeRun = Effect.fn( · 17 unmodified lines
5954 tools: [],
6055 })
6156 .pipe(
62− Effect.map(
63− (result): ExecuteRunResult => ({
64− runId,
65− status: "completed",
66− result,
67− }),
68− ),
69− Effect.catchAll((error) =>
70− Effect.succeed({
71− runId,
72− status: "failed",
73− error: errorToText(error),
74− } satisfies ExecuteRunResult),
75− ),
76− );
57+ Effect.map(
58+ (result): ExecuteRunResult => ({
59+ runId,
60+ status: "completed",
61+ result,
62+ }),
63+ ),
64+ Effect.catchAll((error) =>
65+ Effect.succeed({
66+ runId,
67+ status: "failed",
68+ error: formatRuntimeExecuteError(error),
69+ } satisfies ExecuteRunResult),
70+ ),
71+ );
7772 });
7873
7974 export const ConvexRunExecutorLive = Layer.succeed(
v2/apps/convex/convex/runtimeCallbacks.ts+55−84
@@ -1,27 +1,13 @@
1−import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc";
2−import { executorConfectSchema } from "@executor-v2/persistence-convex";
31 import type {
42 RuntimeToolCallRequest,
53 RuntimeToolCallResult,
64 } from "@executor-v2/sdk";
5+import * as Data from "effect/Data";
76 import * as Effect from "effect/Effect";
7+import * as ParseResult from "effect/ParseResult";
88 import * as Schema from "effect/Schema";
9−import { api } from "./_generated/api";
10−import { httpAction } from "./_generated/server";
11−import { unwrapRpcSuccess } from "./rpc_exit";
12−
13−const runtimeToolCallResultSchema = Schema.Struct({
14− ok: Schema.Boolean,
15− kind: Schema.optional(Schema.String),
16− error: Schema.optional(Schema.String),
17− value: Schema.optional(Schema.Unknown),
18− approvalId: Schema.optional(Schema.String),
19− retryAfterMs: Schema.optional(Schema.Number),
20−});
219
22−const factory = createRpcFactory({
23− schema: executorConfectSchema,
24−});
10+import { httpAction } from "./_generated/server";
2511
2612 export const handleToolCallImpl = (
2713 input: RuntimeToolCallRequest,
@@ -32,35 +18,26 @@ export const handleToolCallImpl = ( · 4 unmodified lines
3218 error: `Convex runtime callback received tool '${input.toolPath}', but callback invocation is not wired yet.`,
3319 });
3420
35−const handleToolCallEndpoint = factory.action({
36− payload: {
37− runId: Schema.String,
38− callId: Schema.String,
39− toolPath: Schema.String,
40− input: Schema.optional(
41− Schema.Record({
42− key: Schema.String,
43− value: Schema.Unknown,
44− }),
45− ),
46− },
47− success: runtimeToolCallResultSchema,
48−});
49−
50−handleToolCallEndpoint.implement((payload) =>
51− handleToolCallImpl({
52− runId: payload.runId,
53− callId: payload.callId,
54− toolPath: payload.toolPath,
55− input: payload.input,
56− }),
57−);
21+class RuntimeToolCallBadRequestError extends Data.TaggedError(
22+ "RuntimeToolCallBadRequestError",
23+)<{
24+ message: string;
25+ details: string;
26+}> {}
5827
59−const runtimeCallbacksRpc = makeRpcModule({
60− handleToolCall: handleToolCallEndpoint,
28+const RuntimeToolCallRequestSchema = Schema.Struct({
29+ runId: Schema.String,
30+ callId: Schema.String,
31+ toolPath: Schema.String,
32+ input: Schema.optional(
33+ Schema.Record({
34+ key: Schema.String,
35+ value: Schema.Unknown,
36+ }),
37+ ),
6138 });
6239
63−export const handleToolCall = runtimeCallbacksRpc.handlers.handleToolCall;
40+const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
6441
6542 const badRequest = (message: string): Response =>
6643 Response.json(
@@ -72,48 +49,42 @@ const badRequest = (message: string): Response => · 5 unmodified lines
7249 { status: 400 },
7350 );
7451
75−export const handleToolCallHttp = httpAction(async (ctx, request) => {
76− let body: unknown;
77− try {
78− body = await request.json();
79− } catch (cause) {
80− return badRequest(
81− `Invalid runtime callback request body: ${
82− cause instanceof Error ? cause.message : String(cause)
83− }`,
84− );
85− }
86−
87− if (typeof body !== "object" || body === null) {
88− return badRequest("Runtime callback request body must be an object");
89− }
52+const formatBadRequestMessage = (error: RuntimeToolCallBadRequestError): string =>
53+ error.details.length > 0 ? `${error.message}: ${error.details}` : error.message;
9054
91− const payload = body as Partial<RuntimeToolCallRequest>;
55+const formatUnknownDetails = (cause: unknown): string => String(cause);
9256
93− if (
94− typeof payload.runId !== "string" ||
95− typeof payload.callId !== "string" ||
96− typeof payload.toolPath !== "string"
97− ) {
98− return badRequest("Runtime callback request body is missing required fields");
99− }
57+const handleToolCallHttpEffect = (
58+ request: Request,
59+): Effect.Effect<Response, never> =>
60+ Effect.gen(function* () {
61+ const body = yield* Effect.tryPromise({
62+ try: () => request.json(),
63+ catch: (cause) =>
64+ new RuntimeToolCallBadRequestError({
65+ message: "Invalid runtime callback request body",
66+ details: formatUnknownDetails(cause),
67+ }),
68+ });
10069
101− let result: RuntimeToolCallResult;
102− try {
103− result = unwrapRpcSuccess(
104− await ctx.runAction(api.runtimeCallbacks.handleToolCall, {
105− runId: payload.runId,
106− callId: payload.callId,
107− toolPath: payload.toolPath,
108− input:
109− payload.input && typeof payload.input === "object"
110− ? (payload.input as Record<string, unknown>)
111− : undefined,
112− }),
113− "runtimeCallbacks.handleToolCall",
70+ const input = yield* decodeRuntimeToolCallRequest(body).pipe(
71+ Effect.mapError(
72+ (cause) =>
73+ new RuntimeToolCallBadRequestError({
74+ message: "Runtime callback request body is invalid",
75+ details: ParseResult.TreeFormatter.formatErrorSync(cause),
76+ }),
77+ ),
11478 );
115− } catch (cause) {
116− return badRequest(cause instanceof Error ? cause.message : String(cause));
117− }
118− return Response.json(result, { status: 200 });
119−});
79+
80+ const result = yield* handleToolCallImpl(input);
81+ return Response.json(result, { status: 200 });
82+ }).pipe(
83+ Effect.catchTag("RuntimeToolCallBadRequestError", (error) =>
84+ Effect.succeed(badRequest(formatBadRequestMessage(error))),
85+ ),
86+ );
87+
88+export const handleToolCallHttp = httpAction((_ctx, request) =>
89+ Effect.runPromise(handleToolCallHttpEffect(request)),
90+);
v2/apps/convex/convex/schema.ts+56−1
@@ -1,3 +1,58 @@
1−import { executorConfectSchema } from "@executor-v2/persistence-convex";
1+import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect";
2+import {
3+ ApprovalSchema,
4+ CredentialRefSchema,
5+ EventEnvelopeSchema,
6+ OAuthTokenSchema,
7+ PolicySchema,
8+ ProfileSchema,
9+ SourceSchema,
10+ SyncStateSchema,
11+ TaskRunSchema,
12+ ToolArtifactSchema,
13+ WorkspaceSchema,
14+} from "@executor-v2/schema";
15+
16+export const executorConfectSchema = defineSchema({
17+ profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]),
18+ workspaces: defineTable(WorkspaceSchema)
19+ .index("by_domainId", ["id"])
20+ .index("by_profileId", ["profileId"]),
21+ sources: defineTable(SourceSchema)
22+ .index("by_domainId", ["id"])
23+ .index("by_workspaceId", ["workspaceId"]),
24+ toolArtifacts: defineTable(ToolArtifactSchema)
25+ .index("by_domainId", ["id"])
26+ .index("by_workspaceId", ["workspaceId"])
27+ .index("by_sourceId", ["sourceId"]),
28+ credentialRefs: defineTable(CredentialRefSchema)
29+ .index("by_domainId", ["id"])
30+ .index("by_workspaceId", ["workspaceId"])
31+ .index("by_sourceId", ["sourceId"]),
32+ oauthTokens: defineTable(OAuthTokenSchema)
33+ .index("by_domainId", ["id"])
34+ .index("by_workspaceId", ["workspaceId"])
35+ .index("by_sourceId", ["sourceId"]),
36+ policies: defineTable(PolicySchema)
37+ .index("by_domainId", ["id"])
38+ .index("by_workspaceId", ["workspaceId"]),
39+ approvals: defineTable(ApprovalSchema)
40+ .index("by_domainId", ["id"])
41+ .index("by_workspaceId", ["workspaceId"])
42+ .index("by_taskRunId", ["taskRunId"]),
43+ taskRuns: defineTable(TaskRunSchema)
44+ .index("by_domainId", ["id"])
45+ .index("by_workspaceId", ["workspaceId"])
46+ .index("by_sessionId", ["sessionId"]),
47+ syncStates: defineTable(SyncStateSchema)
48+ .index("by_domainId", ["id"])
49+ .index("by_workspaceId", ["workspaceId"]),
50+ events: defineTable(EventEnvelopeSchema)
51+ .index("by_domainId", ["id"])
52+ .index("by_workspaceId", ["workspaceId"])
53+ .index("by_workspaceId_sequence", ["workspaceId", "sequence"]),
54+});
55+
56+export type ExecutorConfectTables = TablesFromSchemaDefinition<typeof executorConfectSchema>;
257
358 export default executorConfectSchema.convexSchemaDefinition;
v2/apps/convex/package.json+2−1
@@ -10,9 +10,10 @@ · 9 unmodified lines
1010 },
1111 "dependencies": {
1212 "@executor-v2/confect": "workspace:*",
13+ "@executor-v2/control-plane": "workspace:*",
1314 "@executor-v2/engine": "workspace:*",
1415 "@executor-v2/mcp-gateway": "workspace:*",
15− "@executor-v2/persistence-convex": "workspace:*",
16+ "@executor-v2/schema": "workspace:*",
1617 "@executor-v2/sdk": "workspace:*",
1718 "convex": "catalog:",
1819 "effect": "catalog:"
v2/apps/pm/README.md+5−2
@@ -3,11 +3,14 @@ · 2 unmodified lines
33 Local Process Manager app scaffold for Executor v2.
44
55 Current scaffold includes:
6−- local MCP endpoint at `POST /mcp`
6+- local MCP endpoint at `GET/POST/DELETE /v1/mcp`
77 - health endpoint at `GET /healthz`
88 - MCP tool routing via `@executor-v2/mcp-gateway`
99 - local runtime adapter injection (`makeLocalInProcessRuntimeAdapter()` in entry wiring)
10− runtime callback endpoint at `POST /runtime/tool-call`
10+ runtime callback endpoint at `POST /v1/runtime/tool-call`
11+- control-plane source endpoints at `GET/POST /v1/workspaces/:workspaceId/sources`
12+- control-plane source removal endpoint at `DELETE /v1/workspaces/:workspaceId/sources/:sourceId`
13+- generated OpenAPI spec endpoint at `GET /v1/openapi.json`
1114
1215 App wiring is now split by responsibility:
1316 - `src/config.ts`: Effect config service (`PORT`)
v2/apps/pm/package.json+2−0
@@ -11,9 +11,11 @@ · 10 unmodified lines
1111 "dependencies": {
1212 "@effect/platform": "catalog:",
1313 "@effect/platform-bun": "^0.86.0",
14+ "@executor-v2/control-plane": "workspace:*",
1415 "@executor-v2/engine": "workspace:*",
1516 "@executor-v2/mcp-gateway": "workspace:*",
1617 "@executor-v2/sdk": "workspace:*",
18+ "@executor-v2/persistence-local": "workspace:*",
1719 "effect": "catalog:"
1820 },
1921 "devDependencies": {
v2/apps/pm/src/execute.e2e.test.ts+1−1
@@ -109,7 +109,7 @@ const withMcpClient = (port: number) => · 108 unmodified lines
109109 Effect.tryPromise({
110110 try: async () => {
111111 const transport = new StreamableHTTPClientTransport(
112− new URL(`http://127.0.0.1:${port}/mcp`),
112+ new URL(`http://127.0.0.1:${port}/v1/mcp`),
113113 );
114114 const client = new Client(
115115 { name: "executor-v2-pm-e2e", version: "0.0.0" },
v2/apps/pm/src/http-server.ts+25−4
@@ -5,6 +5,11 @@ import { · 4 unmodified lines
55 HttpServerResponse,
66 } from "@effect/platform";
77 import { BunHttpServer, BunHttpServerRequest } from "@effect/platform-bun";
8+import {
9+ ControlPlaneService,
10+ controlPlaneOpenApiSpec,
11+ makeControlPlaneWebHandler,
12+} from "@executor-v2/control-plane";
813 import * as Effect from "effect/Effect";
914 import * as Layer from "effect/Layer";
1015
@@ -25,17 +30,33 @@ const fromWebHandler = (handler: (request: Request) => Promise<Response>) => · 14 unmodified lines
2530 export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () {
2631 const { port } = yield* PmConfig;
2732 const { handleMcp } = yield* PmMcpHandler;
33+ const controlPlaneService = yield* ControlPlaneService;
34+ const controlPlaneWebHandler = yield* Effect.sync(() =>
35+ makeControlPlaneWebHandler(
36+ Layer.succeed(ControlPlaneService, controlPlaneService),
37+ ),
38+ );
39+
40+ yield* Effect.addFinalizer(() =>
41+ Effect.promise(() => controlPlaneWebHandler.dispose()),
42+ );
2843
2944 const httpLive = HttpRouter.empty.pipe(
3045 HttpRouter.get("/healthz", HttpServerResponse.json({ ok: true, service: "pm" })),
31− HttpRouter.get("/mcp", fromWebHandler(handleMcp)),
32− HttpRouter.post("/mcp", fromWebHandler(handleMcp)),
33− HttpRouter.del("/mcp", fromWebHandler(handleMcp)),
3446 HttpRouter.get("/v1/mcp", fromWebHandler(handleMcp)),
3547 HttpRouter.post("/v1/mcp", fromWebHandler(handleMcp)),
3648 HttpRouter.del("/v1/mcp", fromWebHandler(handleMcp)),
37− HttpRouter.post("/runtime/tool-call", handleToolCallHttp),
3849 HttpRouter.post("/v1/runtime/tool-call", handleToolCallHttp),
50+ HttpRouter.get("/v1/workspaces/:workspaceId/sources", fromWebHandler(controlPlaneWebHandler.handler)),
51+ HttpRouter.post("/v1/workspaces/:workspaceId/sources", fromWebHandler(controlPlaneWebHandler.handler)),
52+ HttpRouter.del(
53+ "/v1/workspaces/:workspaceId/sources/:sourceId",
54+ fromWebHandler(controlPlaneWebHandler.handler),
55+ ),
56+ HttpRouter.get(
57+ "/v1/openapi.json",
58+ HttpServerResponse.unsafeJson(controlPlaneOpenApiSpec),
59+ ),
3960 HttpServer.serve(),
4061 HttpServer.withLogAddress,
4162 Layer.provide(BunHttpServer.layer({ port })),
v2/apps/pm/src/main.ts+13−1
@@ -1,4 +1,7 @@
1+import * as BunContext from "@effect/platform-bun/BunContext";
2+import { ControlPlaneServiceLive } from "@executor-v2/control-plane";
13 import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/engine";
4+import { LocalSourceStoreLive } from "@executor-v2/persistence-local";
25
36 import * as Effect from "effect/Effect";
47 import * as Layer from "effect/Layer";
@@ -18,12 +21,21 @@ const PmMcpDependenciesLive = Layer.merge( · 13 unmodified lines
1821 PmToolProviderRegistryLive,
1922 );
2023
24+const PmControlPlaneDependenciesLive = ControlPlaneServiceLive.pipe(
25+ Layer.provide(
26+ LocalSourceStoreLive({
27+ rootDir: process.env.PM_STATE_ROOT_DIR ?? ".executor-v2/pm-state",
28+ }).pipe(Layer.provide(BunContext.layer)),
29+ ),
30+);
31+
2132 const PmAppLive = Layer.mergeAll(
2233 PmConfigLive,
2334 PmMcpHandlerLive.pipe(Layer.provide(PmMcpDependenciesLive)),
2435 PmToolCallHandlerLive,
36+ PmControlPlaneDependenciesLive,
2537 );
2638
27−const program = startPmHttpServer().pipe(Effect.provide(PmAppLive));
39+const program = Effect.scoped(startPmHttpServer()).pipe(Effect.provide(PmAppLive));
2840
2941 await Effect.runPromise(program);
v2/apps/pm/src/run-executor.ts+11−15
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 makeToolProviderRegistry,
33 ToolProviderRegistryService,
44 type RuntimeAdapter,
5+ type RuntimeExecuteError,
56 } from "@executor-v2/engine";
67 import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
78 import * as Context from "effect/Context";
@@ -19,21 +20,16 @@ export class PmRunExecutor extends Context.Tag("@executor-v2/app-pm/PmRunExecuto · 11 unmodified lines
1920 PmRunExecutorService
2021 >() {}
2122
22−const errorToText = (error: unknown): string => {
23− if (error instanceof Error) {
24− return error.message;
23+const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => {
24+ switch (error._tag) {
25+ case "RuntimeAdapterError":
26+ case "LocalCodeRunnerError":
27+ case "DenoSubprocessRunnerError":
28+ case "ToolProviderError":
29+ return error.details ? `${error.message}: ${error.details}` : error.message;
30+ case "ToolProviderRegistryError":
31+ return error.message;
2532 }
26−
27− if (
28− typeof error === "object" &&
29− error !== null &&
30− "message" in error &&
31− typeof error.message === "string"
32− ) {
33− return error.message;
34− }
35−
36− return String(error);
3733 };
3834
3935 const makeExecuteRun = (runtimeAdapter: RuntimeAdapter) =>
@@ -69,7 +65,7 @@ const makeExecuteRun = (runtimeAdapter: RuntimeAdapter) => · 29 unmodified lines
6965 Effect.succeed({
7066 runId,
7167 status: "failed",
72− error: errorToText(error),
68+ error: formatRuntimeExecuteError(error),
7369 } satisfies ExecuteRunResult),
7470 ),
7571 );
v2/apps/pm/src/tool-call-handler.ts+21−22
@@ -7,6 +7,7 @@ import * as Context from "effect/Context"; · 6 unmodified lines
77 import * as Data from "effect/Data";
88 import * as Effect from "effect/Effect";
99 import * as Layer from "effect/Layer";
10+import * as ParseResult from "effect/ParseResult";
1011 import * as Schema from "effect/Schema";
1112
1213 export type PmToolCallHandlerService = {
@@ -37,29 +38,25 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 24 unmodified lines
3738
3839 const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
3940
40−const errorToText = (error: unknown): string => {
41− if (error instanceof Error) {
42− return error.message;
43− }
44−
45− if (
46− typeof error === "object" &&
47− error !== null &&
48− "message" in error &&
49− typeof error.message === "string"
50− ) {
51− return error.message;
52− }
53−
54− return String(error);
55−};
56−
5741 const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError =>
5842 new PmToolCallHttpRequestError({
5943 message: "Invalid runtime callback request body",
60− details: cause instanceof Error ? cause.message : String(cause),
44+ details: String(cause),
6145 });
6246
47+const decodeRequestPayloadError = (cause: unknown): PmToolCallHttpRequestError =>
48+ new PmToolCallHttpRequestError({
49+ message: "Runtime callback request body is invalid",
50+ details: ParseResult.isParseError(cause)
51+ ? ParseResult.TreeFormatter.formatErrorSync(cause)
52+ : String(cause),
53+ });
54+
55+const formatHttpRequestError = (error: PmToolCallHttpRequestError): string =>
56+ error.details && error.details.length > 0
57+ ? `${error.message}: ${error.details}`
58+ : error.message;
59+
6360 const handleToolCall = Effect.fn("@executor-v2/app-pm/tool-call.handle")(function* (
6461 input: RuntimeToolCallRequest,
6562 ) {
@@ -82,24 +79,26 @@ export const handleToolCallBody = Effect.fn( · 16 unmodified lines
8279 )(function* (body: unknown) {
8380 const handler = yield* PmToolCallHandler;
8481 const input = yield* decodeRuntimeToolCallRequest(body).pipe(
85− Effect.mapError(decodeRequestBodyError),
82+ Effect.mapError(decodeRequestPayloadError),
8683 );
8784
8885 return yield* handler.handleToolCall(input);
8986 });
9087
9188 export const handleToolCallHttp = Effect.gen(function* () {
92− const body = yield* HttpServerRequest.schemaBodyJson(Schema.Unknown);
89+ const body = yield* HttpServerRequest.schemaBodyJson(Schema.Unknown).pipe(
90+ Effect.mapError(decodeRequestBodyError),
91+ );
9392 const result = yield* handleToolCallBody(body);
9493
9594 return yield* HttpServerResponse.json(result, { status: 200 });
9695 }).pipe(
97− Effect.catchAll((error) =>
96+ Effect.catchTag("PmToolCallHttpRequestError", (error) =>
9897 HttpServerResponse.json(
9998 {
10099 ok: false,
101100 kind: "failed",
102− error: errorToText(error),
101+ error: formatHttpRequestError(error),
103102 } satisfies RuntimeToolCallResult,
104103 { status: 400 },
105104 ),
v2/bun.lock+17−17
@@ -21,9 +21,10 @@ · 20 unmodified lines
2121 "name": "@executor-v2/app-convex",
2222 "dependencies": {
2323 "@executor-v2/confect": "workspace:*",
24+ "@executor-v2/control-plane": "workspace:*",
2425 "@executor-v2/engine": "workspace:*",
2526 "@executor-v2/mcp-gateway": "workspace:*",
26− "@executor-v2/persistence-convex": "workspace:*",
27+ "@executor-v2/schema": "workspace:*",
2728 "@executor-v2/sdk": "workspace:*",
2829 "convex": "catalog:",
2930 "effect": "catalog:",
@@ -39,8 +40,10 @@ · 9 unmodified lines
3940 "dependencies": {
4041 "@effect/platform": "catalog:",
4142 "@effect/platform-bun": "^0.86.0",
43+ "@executor-v2/control-plane": "workspace:*",
4244 "@executor-v2/engine": "workspace:*",
4345 "@executor-v2/mcp-gateway": "workspace:*",
46+ "@executor-v2/persistence-local": "workspace:*",
4447 "@executor-v2/sdk": "workspace:*",
4548 "effect": "catalog:",
4649 },
@@ -87,6 +90,17 @@ · 40 unmodified lines
8790 "vitest": "catalog:",
8891 },
8992 },
93+ "packages/control-plane": {
94+ "name": "@executor-v2/control-plane",
95+ "dependencies": {
96+ "@effect-atom/atom": "^0.4.11",
97+ "@effect/platform": "catalog:",
98+ "@executor-v2/persistence-ports": "workspace:*",
99+ "@executor-v2/schema": "workspace:*",
100+ "@executor-v2/source-manager": "workspace:*",
101+ "effect": "catalog:",
102+ },
103+ },
90104 "packages/domain": {
91105 "name": "@executor-v2/domain",
92106 },
@@ -114,9 +128,6 @@ · 21 unmodified lines
114128 "packages/oauth": {
115129 "name": "@executor-v2/oauth",
116130 },
117− "packages/rpc": {
118− "name": "@executor-v2/rpc",
119− },
120131 "packages/schema": {
121132 "name": "@executor-v2/schema",
122133 "dependencies": {
@@ -148,15 +159,6 @@ · 25 unmodified lines
148159 "effect": "catalog:",
149160 },
150161 },
151− "packages/state-convex": {
152− "name": "@executor-v2/persistence-convex",
153− "dependencies": {
154− "@executor-v2/confect": "workspace:*",
155− "@executor-v2/persistence-ports": "workspace:*",
156− "@executor-v2/schema": "workspace:*",
157− "effect": "catalog:",
158− },
159− },
160162 "packages/state-local-file": {
161163 "name": "@executor-v2/persistence-local",
162164 "dependencies": {
@@ -300,6 +302,8 @@ · 137 unmodified lines
300302
301303 "@executor-v2/confect": ["@executor-v2/confect@workspace:packages/confect"],
302304
305+ "@executor-v2/control-plane": ["@executor-v2/control-plane@workspace:packages/control-plane"],
306+
303307 "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"],
304308
305309 "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"],
@@ -308,14 +312,10 @@ · 2 unmodified lines
308312
309313 "@executor-v2/oauth": ["@executor-v2/oauth@workspace:packages/oauth"],
310314
311− "@executor-v2/persistence-convex": ["@executor-v2/persistence-convex@workspace:packages/state-convex"],
312−
313315 "@executor-v2/persistence-local": ["@executor-v2/persistence-local@workspace:packages/state-local-file"],
314316
315317 "@executor-v2/persistence-ports": ["@executor-v2/persistence-ports@workspace:packages/state-contracts"],
316318
317− "@executor-v2/rpc": ["@executor-v2/rpc@workspace:packages/rpc"],
318−
319319 "@executor-v2/schema": ["@executor-v2/schema@workspace:packages/schema"],
320320
321321 "@executor-v2/sdk": ["@executor-v2/sdk@workspace:packages/sdk"],
v2/packages/control-plane/package.jsonrenamed from v2/packages/state-convex/package.json+6−4
@@ -1,5 +1,5 @@
11 {
2− "name": "@executor-v2/persistence-convex",
2+ "name": "@executor-v2/control-plane",
33 "private": true,
44 "type": "module",
55 "exports": {
@@ -9,9 +9,11 @@ · 3 unmodified lines
99 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
1010 },
1111 "dependencies": {
12− "@executor-v2/confect": "workspace:*",
13− "@executor-v2/schema": "workspace:*",
12+ "@effect-atom/atom": "^0.4.11",
13+ "@effect/platform": "catalog:",
1414 "@executor-v2/persistence-ports": "workspace:*",
15+ "@executor-v2/schema": "workspace:*",
16+ "@executor-v2/source-manager": "workspace:*",
1517 "effect": "catalog:"
1618 }
17−}
19+}
v2/packages/control-plane/src/api.ts+26−0
@@ -0,0 +1,26 @@
1+import { HttpApi, OpenApi } from "@effect/platform";
2+
3+import { SourcesApi } from "./sources/api";
4+
5+export {
6+ ControlPlaneBadRequestError,
7+ ControlPlaneStorageError,
8+} from "./errors";
9+
10+export {
11+ RemoveSourceResultSchema,
12+ UpsertSourcePayloadSchema,
13+ type RemoveSourceResult,
14+ type UpsertSourcePayload,
15+} from "./sources/api";
16+
17+export class ControlPlaneApi extends HttpApi.make("controlPlane")
18+ .add(SourcesApi)
19+ .annotateContext(
20+ OpenApi.annotations({
21+ title: "Executor v2 Control Plane API",
22+ description: "Backend-agnostic control plane API",
23+ }),
24+ ) {}
25+
26+export const controlPlaneOpenApiSpec = OpenApi.fromApi(ControlPlaneApi);
v2/packages/control-plane/src/client.ts+159−0
@@ -0,0 +1,159 @@
1+import { Atom, Result } from "@effect-atom/atom";
2+import {
3+ FetchHttpClient,
4+ HttpApiClient,
5+ HttpApiError,
6+ HttpClientError,
7+} from "@effect/platform";
8+import type {
9+ Source,
10+ SourceId,
11+ WorkspaceId,
12+} from "@executor-v2/schema";
13+import * as Effect from "effect/Effect";
14+import * as ParseResult from "effect/ParseResult";
15+
16+import {
17+ ControlPlaneApi,
18+ type ControlPlaneBadRequestError,
19+ type ControlPlaneStorageError,
20+ type RemoveSourceResult,
21+ type UpsertSourcePayload,
22+} from "./api";
23+
24+export type ControlPlaneClientOptions = {
25+ baseUrl: string;
26+};
27+
28+export type ControlPlaneClientError =
29+ | ControlPlaneBadRequestError
30+ | ControlPlaneStorageError
31+ | HttpApiError.HttpApiDecodeError
32+ | HttpClientError.HttpClientError
33+ | ParseResult.ParseError;
34+
35+export type ListSourcesRequest = {
36+ workspaceId: WorkspaceId;
37+};
38+
39+export type UpsertSourceRequest = {
40+ workspaceId: WorkspaceId;
41+ payload: UpsertSourcePayload;
42+};
43+
44+export type RemoveSourceRequest = {
45+ workspaceId: WorkspaceId;
46+ sourceId: SourceId;
47+};
48+
49+export type ControlPlaneReadOptions = {
50+ enabled?: boolean;
51+};
52+
53+export const makeControlPlaneClient = (options: ControlPlaneClientOptions) =>
54+ HttpApiClient.make(ControlPlaneApi, {
55+ baseUrl: options.baseUrl,
56+ });
57+
58+const isReadEnabled = (options?: ControlPlaneReadOptions): boolean =>
59+ options?.enabled ?? true;
60+
61+const makeDisabledResultAtom = <Success, Error>() =>
62+ Atom.make(Result.initial<Success, Error>());
63+
64+export const createControlPlaneAtomClient = (
65+ options: ControlPlaneClientOptions,
66+) => {
67+ const baseLayer = FetchHttpClient.layer;
68+ const runtime = Atom.runtime(baseLayer);
69+
70+ const listSourcesEffect = (
71+ input: ListSourcesRequest,
72+ ): Effect.Effect<ReadonlyArray<Source>, ControlPlaneClientError, never> =>
73+ Effect.gen(function* () {
74+ const client = yield* makeControlPlaneClient(options);
75+ return yield* client.sources.list({
76+ path: {
77+ workspaceId: input.workspaceId,
78+ },
79+ });
80+ }).pipe(Effect.provide(baseLayer));
81+
82+ const upsertSourceEffect = (
83+ input: UpsertSourceRequest,
84+ ): Effect.Effect<Source, ControlPlaneClientError, never> =>
85+ Effect.gen(function* () {
86+ const client = yield* makeControlPlaneClient(options);
87+ return yield* client.sources.upsert({
88+ path: {
89+ workspaceId: input.workspaceId,
90+ },
91+ payload: input.payload,
92+ });
93+ }).pipe(Effect.provide(baseLayer));
94+
95+ const removeSourceEffect = (
96+ input: RemoveSourceRequest,
97+ ): Effect.Effect<RemoveSourceResult, ControlPlaneClientError, never> =>
98+ Effect.gen(function* () {
99+ const client = yield* makeControlPlaneClient(options);
100+ return yield* client.sources.remove({
101+ path: {
102+ workspaceId: input.workspaceId,
103+ sourceId: input.sourceId,
104+ },
105+ });
106+ }).pipe(Effect.provide(baseLayer));
107+
108+ const listSourcesFamily = Atom.family((workspaceId: WorkspaceId) =>
109+ runtime.atom(
110+ listSourcesEffect({
111+ workspaceId,
112+ }),
113+ ),
114+ );
115+
116+ const listDisabledAtom = makeDisabledResultAtom<
117+ ReadonlyArray<Source>,
118+ ControlPlaneClientError
119+ >();
120+
121+ const upsertSourceMutate = runtime.fn<UpsertSourceRequest>()(
122+ Effect.fnUntraced(function* (input) {
123+ return yield* upsertSourceEffect(input);
124+ }),
125+ );
126+
127+ const removeSourceMutate = runtime.fn<RemoveSourceRequest>()(
128+ Effect.fnUntraced(function* (input) {
129+ return yield* removeSourceEffect(input);
130+ }),
131+ );
132+
133+ return {
134+ runtime,
135+ sources: {
136+ list: {
137+ query: (input: ListSourcesRequest, readOptions?: ControlPlaneReadOptions) =>
138+ isReadEnabled(readOptions)
139+ ? listSourcesFamily(input.workspaceId)
140+ : listDisabledAtom,
141+ queryEffect: listSourcesEffect,
142+ queryPromise: (input: ListSourcesRequest) =>
143+ Effect.runPromise(listSourcesEffect(input)),
144+ },
145+ upsert: {
146+ mutate: upsertSourceMutate,
147+ mutateEffect: upsertSourceEffect,
148+ mutatePromise: (input: UpsertSourceRequest) =>
149+ Effect.runPromise(upsertSourceEffect(input)),
150+ },
151+ remove: {
152+ mutate: removeSourceMutate,
153+ mutateEffect: removeSourceEffect,
154+ mutatePromise: (input: RemoveSourceRequest) =>
155+ Effect.runPromise(removeSourceEffect(input)),
156+ },
157+ },
158+ };
159+};
v2/packages/control-plane/src/errors.ts+22−0
@@ -0,0 +1,22 @@
1+import { HttpApiSchema } from "@effect/platform";
2+import * as Schema from "effect/Schema";
3+
4+export class ControlPlaneBadRequestError extends Schema.TaggedError<ControlPlaneBadRequestError>()(
5+ "ControlPlaneBadRequestError",
6+ {
7+ operation: Schema.String,
8+ message: Schema.String,
9+ details: Schema.String,
10+ },
11+ HttpApiSchema.annotations({ status: 400 }),
12+) {}
13+
14+export class ControlPlaneStorageError extends Schema.TaggedError<ControlPlaneStorageError>()(
15+ "ControlPlaneStorageError",
16+ {
17+ operation: Schema.String,
18+ message: Schema.String,
19+ details: Schema.String,
20+ },
21+ HttpApiSchema.annotations({ status: 500 }),
22+) {}
v2/packages/control-plane/src/http.ts+20−0
@@ -0,0 +1,20 @@
1+import { HttpApiBuilder, HttpServer } from "@effect/platform";
2+import * as Layer from "effect/Layer";
3+
4+import { ControlPlaneApi } from "./api";
5+import { ControlPlaneService } from "./service";
6+import { ControlPlaneSourcesLive } from "./sources/http";
7+
8+export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(
9+ Layer.provide(ControlPlaneSourcesLive),
10+);
11+
12+export const makeControlPlaneWebHandler = <E>(
13+ serviceLayer: Layer.Layer<ControlPlaneService, E, never>,
14+) =>
15+ HttpApiBuilder.toWebHandler(
16+ Layer.mergeAll(
17+ ControlPlaneApiLive.pipe(Layer.provide(serviceLayer)),
18+ HttpServer.layerContext,
19+ ),
20+ );
v2/packages/control-plane/src/index.ts+44−0
@@ -0,0 +1,44 @@
1+export {
2+ ControlPlaneApi,
3+ RemoveSourceResultSchema,
4+ UpsertSourcePayloadSchema,
5+ controlPlaneOpenApiSpec,
6+ type RemoveSourceResult,
7+ type UpsertSourcePayload,
8+} from "./api";
9+
10+export {
11+ ControlPlaneBadRequestError,
12+ ControlPlaneStorageError,
13+} from "./errors";
14+
15+export {
16+ ControlPlaneService,
17+ ControlPlaneServiceLive,
18+ makeControlPlaneService,
19+ type ControlPlaneServiceShape,
20+} from "./service";
21+
22+export {
23+ ControlPlaneApiLive,
24+ makeControlPlaneWebHandler,
25+} from "./http";
26+
27+export {
28+ ControlPlaneSourcesLive,
29+ makeControlPlaneSourcesService,
30+ type ControlPlaneSourcesServiceShape,
31+ type RemoveSourceInput,
32+ type UpsertSourceInput,
33+} from "./sources";
34+
35+export {
36+ createControlPlaneAtomClient,
37+ makeControlPlaneClient,
38+ type ControlPlaneClientError,
39+ type ControlPlaneClientOptions,
40+ type ControlPlaneReadOptions,
41+ type ListSourcesRequest,
42+ type RemoveSourceRequest,
43+ type UpsertSourceRequest,
44+} from "./client";
v2/packages/control-plane/src/service.ts+37−0
@@ -0,0 +1,37 @@
1+import { SourceCatalog, SourceCatalogLive } from "@executor-v2/source-manager/source-catalog";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+
6+import {
7+ makeControlPlaneSourcesService,
8+ type ControlPlaneSourcesServiceShape,
9+} from "./sources/service";
10+
11+export type ControlPlaneServiceShape = ControlPlaneSourcesServiceShape;
12+
13+export class ControlPlaneService extends Context.Tag("@executor-v2/control-plane/ControlPlaneService")<
14+ ControlPlaneService,
15+ ControlPlaneServiceShape
16+>() {}
17+
18+export const makeControlPlaneService = (
19+ services: {
20+ sources: ControlPlaneSourcesServiceShape;
21+ },
22+): ControlPlaneServiceShape => ({
23+ ...services.sources,
24+});
25+
26+export const ControlPlaneServiceLive = Layer.effect(
27+ ControlPlaneService,
28+ Effect.gen(function* () {
29+ const sourceCatalog = yield* SourceCatalog;
30+
31+ return ControlPlaneService.of(
32+ makeControlPlaneService({
33+ sources: makeControlPlaneSourcesService(sourceCatalog),
34+ }),
35+ );
36+ }),
37+).pipe(Layer.provide(SourceCatalogLive));
v2/packages/control-plane/src/sources/api.ts+51−0
@@ -0,0 +1,51 @@
1+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import { SourceIdSchema, SourceSchema, WorkspaceIdSchema } from "@executor-v2/schema";
3+import * as Schema from "effect/Schema";
4+
5+import { ControlPlaneBadRequestError, ControlPlaneStorageError } from "../errors";
6+
7+const RequiredUpsertSourcePayloadSchema = SourceSchema.pipe(
8+ Schema.pick("name", "kind", "endpoint"),
9+);
10+
11+const OptionalUpsertSourcePayloadSchema = SourceSchema.pipe(
12+ Schema.pick("id", "status", "enabled", "configJson", "sourceHash", "lastError"),
13+ Schema.partialWith({ exact: true }),
14+);
15+
16+export const UpsertSourcePayloadSchema = RequiredUpsertSourcePayloadSchema.pipe(
17+ Schema.extend(OptionalUpsertSourcePayloadSchema),
18+);
19+
20+export type UpsertSourcePayload = typeof UpsertSourcePayloadSchema.Type;
21+
22+export const RemoveSourceResultSchema = Schema.Struct({
23+ removed: Schema.Boolean,
24+});
25+
26+export type RemoveSourceResult = typeof RemoveSourceResultSchema.Type;
27+
28+const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
29+const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
30+
31+export class SourcesApi extends HttpApiGroup.make("sources")
32+ .add(
33+ HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`
34+ .addSuccess(Schema.Array(SourceSchema))
35+ .addError(ControlPlaneBadRequestError)
36+ .addError(ControlPlaneStorageError),
37+ )
38+ .add(
39+ HttpApiEndpoint.post("upsert")`/workspaces/${workspaceIdParam}/sources`
40+ .setPayload(UpsertSourcePayloadSchema)
41+ .addSuccess(SourceSchema)
42+ .addError(ControlPlaneBadRequestError)
43+ .addError(ControlPlaneStorageError),
44+ )
45+ .add(
46+ HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}`
47+ .addSuccess(RemoveSourceResultSchema)
48+ .addError(ControlPlaneBadRequestError)
49+ .addError(ControlPlaneStorageError),
50+ )
51+ .prefix("/v1") {}
v2/packages/control-plane/src/sources/http.ts+67−0
@@ -0,0 +1,67 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { type SourceStoreError } from "@executor-v2/persistence-ports";
3+import * as Effect from "effect/Effect";
4+
5+import { ControlPlaneBadRequestError, ControlPlaneStorageError } from "../errors";
6+import { ControlPlaneService } from "../service";
7+import { ControlPlaneApi } from "../api";
8+
9+const toStorageError = (
10+ operation: string,
11+ cause: SourceStoreError,
12+): ControlPlaneStorageError =>
13+ new ControlPlaneStorageError({
14+ operation,
15+ message: "Control plane operation failed",
16+ details: cause.details ?? cause.message,
17+ });
18+
19+export const ControlPlaneSourcesLive = HttpApiBuilder.group(
20+ ControlPlaneApi,
21+ "sources",
22+ (handlers) =>
23+ handlers
24+ .handle("list", ({ path }) =>
25+ Effect.gen(function* () {
26+ const service = yield* ControlPlaneService;
27+ return yield* service.listSources(path.workspaceId);
28+ }).pipe(
29+ Effect.catchTag("SourceStoreError", (cause) =>
30+ toStorageError("sources.list", cause),
31+ ),
32+ ),
33+ )
34+ .handle("upsert", ({ path, payload }) =>
35+ Effect.gen(function* () {
36+ const service = yield* ControlPlaneService;
37+ return yield* service.upsertSource({
38+ workspaceId: path.workspaceId,
39+ payload,
40+ });
41+ }).pipe(
42+ Effect.catchTag("SourceCatalogValidationError", (error) =>
43+ new ControlPlaneBadRequestError({
44+ operation: error.operation,
45+ message: error.message,
46+ details: error.details,
47+ }),
48+ ),
49+ Effect.catchTag("SourceStoreError", (cause) =>
50+ toStorageError("sources.upsert", cause),
51+ ),
52+ ),
53+ )
54+ .handle("remove", ({ path }) =>
55+ Effect.gen(function* () {
56+ const service = yield* ControlPlaneService;
57+ return yield* service.removeSource({
58+ workspaceId: path.workspaceId,
59+ sourceId: path.sourceId,
60+ });
61+ }).pipe(
62+ Effect.catchTag("SourceStoreError", (cause) =>
63+ toStorageError("sources.remove", cause),
64+ ),
65+ ),
66+ ),
67+);
v2/packages/control-plane/src/sources/index.ts+16−0
@@ -0,0 +1,16 @@
1+export {
2+ RemoveSourceResultSchema,
3+ SourcesApi,
4+ UpsertSourcePayloadSchema,
5+ type RemoveSourceResult,
6+ type UpsertSourcePayload,
7+} from "./api";
8+
9+export {
10+ makeControlPlaneSourcesService,
11+ type ControlPlaneSourcesServiceShape,
12+ type RemoveSourceInput,
13+ type UpsertSourceInput,
14+} from "./service";
15+
16+export { ControlPlaneSourcesLive } from "./http";
v2/packages/control-plane/src/sources/service.ts+45−0
@@ -0,0 +1,45 @@
1+import { type SourceStoreError } from "@executor-v2/persistence-ports";
2+import {
3+ SourceCatalogValidationError,
4+ type SourceCatalogService,
5+} from "@executor-v2/source-manager/source-catalog";
6+import { type Source, type SourceId, type WorkspaceId } from "@executor-v2/schema";
7+import * as Effect from "effect/Effect";
8+
9+import type { RemoveSourceResult, UpsertSourcePayload } from "./api";
10+
11+export type UpsertSourceInput = {
12+ workspaceId: WorkspaceId;
13+ payload: UpsertSourcePayload;
14+};
15+
16+export type RemoveSourceInput = {
17+ workspaceId: WorkspaceId;
18+ sourceId: SourceId;
19+};
20+
21+export type ControlPlaneSourcesServiceShape = {
22+ listSources: (
23+ workspaceId: WorkspaceId,
24+ ) => Effect.Effect<ReadonlyArray<Source>, SourceStoreError>;
25+ upsertSource: (
26+ input: UpsertSourceInput,
27+ ) => Effect.Effect<Source, SourceStoreError | SourceCatalogValidationError>;
28+ removeSource: (input: RemoveSourceInput) => Effect.Effect<RemoveSourceResult, SourceStoreError>;
29+};
30+
31+export const makeControlPlaneSourcesService = (
32+ sourceCatalog: SourceCatalogService,
33+): ControlPlaneSourcesServiceShape => ({
34+ listSources: (workspaceId) => sourceCatalog.listSources(workspaceId),
35+ upsertSource: (input) =>
36+ sourceCatalog.upsertSource({
37+ workspaceId: input.workspaceId,
38+ payload: input.payload,
39+ }),
40+ removeSource: (input) =>
41+ sourceCatalog.removeSource({
42+ workspaceId: input.workspaceId,
43+ sourceId: input.sourceId,
44+ }),
45+});
v2/packages/control-plane/tsconfig.jsonrenamed from v2/packages/rpc/tsconfig.json+4−1
@@ -1,4 +1,7 @@
11 {
22 "extends": "../../tsconfig.json",
3+ "compilerOptions": {
4+ "types": ["node", "bun"]
5+ },
36 "include": ["src"]
4−}
7+}
v2/packages/engine/src/run-client.ts+17−18
@@ -9,7 +9,11 @@ import { · 8 unmodified lines
99 type ToolProvider,
1010 type ToolProviderRegistry,
1111 } from "./tool-providers";
12−import type { RuntimeAdapter, RuntimeRunnableTool } from "./runtime-adapters";
12+import type {
13+ RuntimeAdapter,
14+ RuntimeExecuteError,
15+ RuntimeRunnableTool,
16+} from "./runtime-adapters";
1317
1418 export type RuntimeRunClientExecuteInput = {
1519 code: string;
@@ -57,21 +61,16 @@ export type CreateInMemoryRuntimeRunClientOptions = { · 41 unmodified lines
5761 makeRunId?: () => string;
5862 };
5963
60−const errorToText = (error: unknown): string => {
61− if (error instanceof Error) {
62− return error.message;
64+const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => {
65+ switch (error._tag) {
66+ case "RuntimeAdapterError":
67+ case "LocalCodeRunnerError":
68+ case "DenoSubprocessRunnerError":
69+ case "ToolProviderError":
70+ return error.details ? `${error.message}: ${error.details}` : error.message;
71+ case "ToolProviderRegistryError":
72+ return error.message;
6373 }
64−
65− if (
66− typeof error === "object" &&
67− error !== null &&
68− "message" in error &&
69− typeof error.message === "string"
70− ) {
71− return error.message;
72− }
73−
74− return String(error);
7574 };
7675
7776 const toInMemoryDescriptor = (
@@ -127,7 +126,7 @@ const makeInMemoryToolProvider = ( · 49 unmodified lines
127126 operation: "invoke",
128127 providerKind: "in_memory",
129128 message: `In-memory tool invocation failed: ${input.tool.toolId}`,
130− details: cause instanceof Error ? cause.message : String(cause),
129+ details: String(cause),
131130 }),
132131 });
133132 },
@@ -152,7 +151,7 @@ export const createRuntimeRunClient = ( · 18 unmodified lines
152151 return {
153152 runId,
154153 status: "failed",
155− error: errorToText(availabilityResult.left),
154+ error: "Runtime availability check failed",
156155 };
157156 }
158157
@@ -185,7 +184,7 @@ export const createRuntimeRunClient = ( · 26 unmodified lines
185184 return {
186185 runId,
187186 status: "failed",
188− error: errorToText(executionResult.left),
187+ error: formatRuntimeExecuteError(executionResult.left),
189188 };
190189 }
191190
v2/packages/rpc/README.md+0−3
@@ -1,3 +0,0 @@
1−# rpc
2−
3−Effect RPC contract scaffold for Executor v2.
v2/packages/rpc/package.json+0−8
@@ -1,8 +0,0 @@
1−{
2− "name": "@executor-v2/rpc",
3− "private": true,
4− "type": "module",
5− "scripts": {
6− "typecheck": "bunx tsc --noEmit -p tsconfig.json"
7− }
8−}
v2/packages/rpc/src/index.ts+0−1
@@ -1 +0,0 @@
1−export {};
v2/packages/source-manager/package.json+2−1
@@ -3,7 +3,8 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6− ".": "./src/index.ts"
6+ ".": "./src/index.ts",
7+ "./source-catalog": "./src/source-catalog.ts"
78 },
89 "scripts": {
910 "test": "bunx vitest run --no-watch src/*.test.ts",
v2/packages/source-manager/src/index.ts+12−0
@@ -5,3 +5,15 @@ export { · 4 unmodified lines
55 type RefreshOpenApiArtifactRequest,
66 type SourceManagerService,
77 } from "./service";
8+
9+export {
10+ SourceCatalog,
11+ SourceCatalogLive,
12+ SourceCatalogValidationError,
13+ makeSourceCatalogService,
14+ type RemoveSourceRequest,
15+ type RemoveSourceResult,
16+ type SourceCatalogService,
17+ type UpsertSourcePayload,
18+ type UpsertSourceRequest,
19+} from "./source-catalog";
v2/packages/source-manager/src/source-catalog.ts+158−0
@@ -0,0 +1,158 @@
1+import {
2+ SourceStoreError,
3+ SourceStoreService,
4+ type SourceStore,
5+} from "@executor-v2/persistence-ports";
6+import {
7+ SourceIdSchema,
8+ SourceSchema,
9+ type Source,
10+ type SourceId,
11+ type SourceKind,
12+ type SourceStatus,
13+ type WorkspaceId,
14+} from "@executor-v2/schema";
15+import * as Context from "effect/Context";
16+import * as Data from "effect/Data";
17+import * as Effect from "effect/Effect";
18+import * as Layer from "effect/Layer";
19+import * as Option from "effect/Option";
20+import * as ParseResult from "effect/ParseResult";
21+import * as Schema from "effect/Schema";
22+
23+export class SourceCatalogValidationError extends Data.TaggedError(
24+ "SourceCatalogValidationError",
25+)<{
26+ operation: string;
27+ message: string;
28+ details: string;
29+}> {}
30+
31+export type UpsertSourcePayload = {
32+ id?: SourceId;
33+ name: string;
34+ kind: SourceKind;
35+ endpoint: string;
36+ status?: SourceStatus;
37+ enabled?: boolean;
38+ configJson?: string;
39+ sourceHash?: string | null;
40+ lastError?: string | null;
41+};
42+
43+export type UpsertSourceRequest = {
44+ workspaceId: WorkspaceId;
45+ payload: UpsertSourcePayload;
46+ now?: () => number;
47+};
48+
49+export type RemoveSourceRequest = {
50+ workspaceId: WorkspaceId;
51+ sourceId: SourceId;
52+};
53+
54+export type RemoveSourceResult = {
55+ removed: boolean;
56+};
57+
58+export type SourceCatalogService = {
59+ listSources: (
60+ workspaceId: WorkspaceId,
61+ ) => Effect.Effect<ReadonlyArray<Source>, SourceStoreError>;
62+ upsertSource: (
63+ input: UpsertSourceRequest,
64+ ) => Effect.Effect<Source, SourceStoreError | SourceCatalogValidationError>;
65+ removeSource: (
66+ input: RemoveSourceRequest,
67+ ) => Effect.Effect<RemoveSourceResult, SourceStoreError>;
68+};
69+
70+export class SourceCatalog extends Context.Tag("@executor-v2/source-manager/SourceCatalog")<
71+ SourceCatalog,
72+ SourceCatalogService
73+>() {}
74+
75+const decodeSourceId = Schema.decodeUnknown(SourceIdSchema);
76+const decodeSource = Schema.decodeUnknown(SourceSchema);
77+
78+const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`;
79+
80+const sortSources = (sources: ReadonlyArray<Source>): Array<Source> =>
81+ [...sources].sort((left, right) => {
82+ const leftName = left.name.toLowerCase();
83+ const rightName = right.name.toLowerCase();
84+
85+ if (leftName === rightName) {
86+ return sourceStoreKey(left).localeCompare(sourceStoreKey(right));
87+ }
88+
89+ return leftName.localeCompare(rightName);
90+ });
91+
92+const toSourceCatalogValidationError = (
93+ operation: string,
94+ cause: unknown,
95+): SourceCatalogValidationError =>
96+ new SourceCatalogValidationError({
97+ operation,
98+ message: "Invalid source payload",
99+ details: ParseResult.isParseError(cause)
100+ ? ParseResult.TreeFormatter.formatErrorSync(cause)
101+ : String(cause),
102+ });
103+
104+const createSourceId = (): Effect.Effect<SourceId, SourceCatalogValidationError> =>
105+ decodeSourceId(`src_${crypto.randomUUID()}`).pipe(
106+ Effect.mapError((cause) => toSourceCatalogValidationError("sources.upsert.id", cause)),
107+ );
108+
109+const timestamp = (clock?: () => number): number => clock?.() ?? Date.now();
110+
111+export const makeSourceCatalogService = (
112+ sourceStore: SourceStore,
113+): SourceCatalogService => ({
114+ listSources: (workspaceId) =>
115+ sourceStore.listByWorkspace(workspaceId).pipe(Effect.map(sortSources)),
116+ upsertSource: (input) =>
117+ Effect.gen(function* () {
118+ const sourceId = input.payload.id ?? (yield* createSourceId());
119+ const now = timestamp(input.now);
120+ const existingOption = yield* sourceStore.getById(input.workspaceId, sourceId);
121+ const existing = Option.getOrUndefined(existingOption);
122+
123+ const source = yield* decodeSource({
124+ id: sourceId,
125+ workspaceId: input.workspaceId,
126+ name: input.payload.name,
127+ kind: input.payload.kind,
128+ endpoint: input.payload.endpoint,
129+ status: input.payload.status ?? "draft",
130+ enabled: input.payload.enabled ?? true,
131+ configJson: input.payload.configJson ?? "{}",
132+ sourceHash: input.payload.sourceHash ?? null,
133+ lastError: input.payload.lastError ?? null,
134+ createdAt: existing?.createdAt ?? now,
135+ updatedAt: now,
136+ }).pipe(
137+ Effect.mapError((cause) =>
138+ toSourceCatalogValidationError("sources.upsert.payload", cause),
139+ ),
140+ );
141+
142+ yield* sourceStore.upsert(source);
143+ return source;
144+ }),
145+ removeSource: (input) =>
146+ sourceStore.removeById(input.workspaceId, input.sourceId).pipe(
147+ Effect.map((removed): RemoveSourceResult => ({ removed })),
148+ ),
149+});
150+
151+export const SourceCatalogLive = Layer.effect(
152+ SourceCatalog,
153+ Effect.gen(function* () {
154+ const sourceStore = yield* SourceStoreService;
155+
156+ return SourceCatalog.of(makeSourceCatalogService(sourceStore));
157+ }),
158+);
v2/packages/state-convex/README.md+0−8
@@ -1,8 +0,0 @@
1−# persistence-convex
2−
3−Convex-backed persistence adapter scaffold for Executor v2.
4−
5−Current scaffold includes:
6−- Convex table-typed ID schemas in `src/convex-ids.ts`
7−- shared Convex schema projection in `src/schema.ts`
8−- first adapter implementation for `ProfileStore` in `src/profiles.ts`
v2/packages/state-convex/src/convex-ids.ts+0−25
@@ -1,25 +0,0 @@
1−import { Id } from "@executor-v2/confect";
2−
3−export const ProfileDocIdSchema = Id("profiles");
4−export const WorkspaceDocIdSchema = Id("workspaces");
5−export const SourceDocIdSchema = Id("sources");
6−export const ToolArtifactDocIdSchema = Id("toolArtifacts");
7−export const CredentialRefDocIdSchema = Id("credentialRefs");
8−export const OAuthTokenDocIdSchema = Id("oauthTokens");
9−export const PolicyDocIdSchema = Id("policies");
10−export const ApprovalDocIdSchema = Id("approvals");
11−export const TaskRunDocIdSchema = Id("taskRuns");
12−export const SyncStateDocIdSchema = Id("syncStates");
13−export const EventDocIdSchema = Id("events");
14−
15−export type ProfileDocId = typeof ProfileDocIdSchema.Type;
16−export type WorkspaceDocId = typeof WorkspaceDocIdSchema.Type;
17−export type SourceDocId = typeof SourceDocIdSchema.Type;
18−export type ToolArtifactDocId = typeof ToolArtifactDocIdSchema.Type;
19−export type CredentialRefDocId = typeof CredentialRefDocIdSchema.Type;
20−export type OAuthTokenDocId = typeof OAuthTokenDocIdSchema.Type;
21−export type PolicyDocId = typeof PolicyDocIdSchema.Type;
22−export type ApprovalDocId = typeof ApprovalDocIdSchema.Type;
23−export type TaskRunDocId = typeof TaskRunDocIdSchema.Type;
24−export type SyncStateDocId = typeof SyncStateDocIdSchema.Type;
25−export type EventDocId = typeof EventDocIdSchema.Type;
v2/packages/state-convex/src/index.ts+0−26
@@ -1,26 +0,0 @@
1−export {
2− ApprovalDocIdSchema,
3− CredentialRefDocIdSchema,
4− EventDocIdSchema,
5− OAuthTokenDocIdSchema,
6− PolicyDocIdSchema,
7− ProfileDocIdSchema,
8− SourceDocIdSchema,
9− SyncStateDocIdSchema,
10− TaskRunDocIdSchema,
11− ToolArtifactDocIdSchema,
12− WorkspaceDocIdSchema,
13− type ApprovalDocId,
14− type CredentialRefDocId,
15− type EventDocId,
16− type OAuthTokenDocId,
17− type PolicyDocId,
18− type ProfileDocId,
19− type SourceDocId,
20− type SyncStateDocId,
21− type TaskRunDocId,
22− type ToolArtifactDocId,
23− type WorkspaceDocId,
24−} from "./convex-ids";
25−export { makeConvexProfileStore } from "./profiles";
26−export { executorConfectSchema, type ExecutorConfectTables } from "./schema";
v2/packages/state-convex/src/profiles.ts+0−33
@@ -1,33 +0,0 @@
1−import type {
2− ConfectQueryCtx,
3− DocumentFromTable,
4−} from "@executor-v2/confect";
5−import { ProfileSchema, type Profile, type ProfileId } from "@executor-v2/schema";
6−import type { ProfileStore } from "@executor-v2/persistence-ports";
7−import * as Effect from "effect/Effect";
8−import { pipe } from "effect/Function";
9−import * as Option from "effect/Option";
10−import * as Schema from "effect/Schema";
11−
12−import type { ExecutorConfectTables } from "./schema";
13−
14−type ProfileDocument = DocumentFromTable<ExecutorConfectTables, "profiles">;
15−
16−const toProfile = (document: ProfileDocument): Profile => {
17− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...profile } = document;
18− return Schema.decodeUnknownSync(ProfileSchema)(profile);
19−};
20−
21−export const makeConvexProfileStore = (
22− ctx: ConfectQueryCtx<ExecutorConfectTables>,
23−): ProfileStore => ({
24− getById: (id: ProfileId) =>
25− pipe(
26− ctx.db
27− .query("profiles")
28− .withIndex("by_domainId", (q) => q.eq("id", id))
29− .unique(),
30− Effect.orDie,
31− Effect.map(Option.map(toProfile)),
32− ),
33−});
v2/packages/state-convex/src/schema.ts+0−56
@@ -1,56 +0,0 @@
1−import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect";
2−import {
3− ApprovalSchema,
4− CredentialRefSchema,
5− EventEnvelopeSchema,
6− OAuthTokenSchema,
7− PolicySchema,
8− ProfileSchema,
9− SourceSchema,
10− SyncStateSchema,
11− TaskRunSchema,
12− ToolArtifactSchema,
13− WorkspaceSchema,
14−} from "@executor-v2/schema";
15−
16−export const executorConfectSchema = defineSchema({
17− profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]),
18− workspaces: defineTable(WorkspaceSchema)
19− .index("by_domainId", ["id"])
20− .index("by_profileId", ["profileId"]),
21− sources: defineTable(SourceSchema)
22− .index("by_domainId", ["id"])
23− .index("by_workspaceId", ["workspaceId"]),
24− toolArtifacts: defineTable(ToolArtifactSchema)
25− .index("by_domainId", ["id"])
26− .index("by_workspaceId", ["workspaceId"])
27− .index("by_sourceId", ["sourceId"]),
28− credentialRefs: defineTable(CredentialRefSchema)
29− .index("by_domainId", ["id"])
30− .index("by_workspaceId", ["workspaceId"])
31− .index("by_sourceId", ["sourceId"]),
32− oauthTokens: defineTable(OAuthTokenSchema)
33− .index("by_domainId", ["id"])
34− .index("by_workspaceId", ["workspaceId"])
35− .index("by_sourceId", ["sourceId"]),
36− policies: defineTable(PolicySchema)
37− .index("by_domainId", ["id"])
38− .index("by_workspaceId", ["workspaceId"]),
39− approvals: defineTable(ApprovalSchema)
40− .index("by_domainId", ["id"])
41− .index("by_workspaceId", ["workspaceId"])
42− .index("by_taskRunId", ["taskRunId"]),
43− taskRuns: defineTable(TaskRunSchema)
44− .index("by_domainId", ["id"])
45− .index("by_workspaceId", ["workspaceId"])
46− .index("by_sessionId", ["sessionId"]),
47− syncStates: defineTable(SyncStateSchema)
48− .index("by_domainId", ["id"])
49− .index("by_workspaceId", ["workspaceId"]),
50− events: defineTable(EventEnvelopeSchema)
51− .index("by_domainId", ["id"])
52− .index("by_workspaceId", ["workspaceId"])
53− .index("by_workspaceId_sequence", ["workspaceId", "sequence"]),
54−});
55−
56−export type ExecutorConfectTables = TablesFromSchemaDefinition<typeof executorConfectSchema>;
v2/packages/state-convex/tsconfig.json+0−4
@@ -1,4 +0,0 @@
1−{
2− "extends": "../../tsconfig.json",
3− "include": ["src"]
4−}