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.
Unified · Split
| @@ -64,7 +64,7 @@ Control is optional capability, not required for execute-only environments. · 63 unmodified lines | |||
| 64 | How clients talk to executor. | 64 | How clients talk to executor. |
| 65 | 65 | ||
| 66 | - MCP server/gateway | 66 | - MCP server/gateway |
| 67 | - OpenAPI/RPC control API | 67 | - OpenAPI control API |
| 68 | - AI SDK tool bridge | 68 | - AI SDK tool bridge |
| 69 | 69 | ||
| 70 | Transport does not own core execution semantics. | 70 | 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 | |||
| 167 | 167 | ||
| 168 | --- | 168 | --- |
| 169 | 169 | ||
| 170 | ## 8) MCP, RPC, and AI SDK strategy | 170 | ## 8) MCP, OpenAPI, and AI SDK strategy |
| 171 | 171 | ||
| 172 | ### MCP | 172 | ### MCP |
| 173 | 173 | ||
| @@ -179,7 +179,7 @@ Top-level model-facing tools stay minimal: · 5 unmodified lines | |||
| 179 | 179 | ||
| 180 | ### Control API | 180 | ### Control API |
| 181 | 181 | ||
| 182 | Control is exposed through Executor OpenAPI/RPC contracts. | 182 | Control is exposed through Executor OpenAPI contracts. |
| 183 | 183 | ||
| 184 | - Host/app control calls should use control API directly. | 184 | - Host/app control calls should use control API directly. |
| 185 | - Model control calls can use `tools.executor.*` inside `execute` code. | 185 | - Model control calls can use `tools.executor.*` inside `execute` code. |
| @@ -287,16 +287,15 @@ v2/ · 101 unmodified lines | |||
| 287 | schema/ | 287 | schema/ |
| 288 | state-contracts/ | 288 | state-contracts/ |
| 289 | state-local-file/ | 289 | state-local-file/ |
| 290 | state-convex/ | ||
| 291 | domain/ | 290 | domain/ |
| 292 | approvals/ | 291 | approvals/ |
| 293 | source-manager/ | 292 | source-manager/ |
| 293 | control-plane/ | ||
| 294 | engine/ | 294 | engine/ |
| 295 | mcp-gateway/ | 295 | mcp-gateway/ |
| 296 | oauth/ | 296 | oauth/ |
| 297 | secrets/ | 297 | secrets/ |
| 298 | sync/ | 298 | sync/ |
| 299 | rpc/ | ||
| 300 | sdk/ | 299 | sdk/ |
| 301 | ai-sdk-adapter/ | 300 | ai-sdk-adapter/ |
| 302 | ``` | 301 | ``` |
| @@ -313,15 +312,15 @@ Planned follow-up split (after behavior stabilizes): · 10 unmodified lines | |||
| 313 | confect -> schema -> state-contracts | 312 | confect -> schema -> state-contracts |
| 314 | 313 | ||
| 315 | state-contracts -> state-local-file | 314 | state-contracts -> state-local-file |
| 316 | state-contracts -> state-convex | ||
| 317 | 315 | ||
| 318 | schema + state-contracts + oauth + secrets -> domain | 316 | schema + state-contracts + oauth + secrets -> domain |
| 319 | domain -> approvals/source-manager/engine | 317 | domain -> approvals/source-manager/engine |
| 318 | source-manager + schema -> control-plane | ||
| 320 | approvals/source-manager/engine -> mcp-gateway | 319 | approvals/source-manager/engine -> mcp-gateway |
| 321 | domain/features -> rpc -> sdk -> ai-sdk-adapter | 320 | domain/features -> sdk -> ai-sdk-adapter |
| 322 | 321 | ||
| 323 | apps/pm -> mcp-gateway + domain + state-local-file + rpc | 322 | apps/pm -> mcp-gateway + domain + state-local-file + control-plane |
| 324 | apps/convex -> mcp-gateway + domain + state-convex + rpc | 323 | apps/convex -> mcp-gateway + domain + control-plane |
| 325 | apps/cli/web -> sdk (and/or ai-sdk bridge) | 324 | apps/cli/web -> sdk (and/or ai-sdk bridge) |
| 326 | ``` | 325 | ``` |
| 327 | Rules: | 326 | Rules: |
| @@ -367,7 +366,7 @@ Rules: · 39 unmodified lines | |||
| 367 | ### Phase 5: transport polish | 366 | ### Phase 5: transport polish |
| 368 | 367 | ||
| 369 | - MCP surface stabilization | 368 | - MCP surface stabilization |
| 370 | - OpenAPI/RPC control surface stabilization | 369 | - OpenAPI control surface stabilization |
| 371 | - AI SDK bridge (`toAiSdkTools`) and docs/examples | 370 | - AI SDK bridge (`toAiSdkTools`) and docs/examples |
| 372 | 371 | ||
| 373 | --- | 372 | --- |
| @@ -414,7 +413,7 @@ Still missing: · 40 unmodified lines | |||
| 414 | - shared tool invocation service wired with persisted approval lifecycle in v2 path | 413 | - shared tool invocation service wired with persisted approval lifecycle in v2 path |
| 415 | - cloudflare-worker-loader runtime adapter implementation in v2 runtime path | 414 | - cloudflare-worker-loader runtime adapter implementation in v2 runtime path |
| 416 | - parity conformance tests for runtime/provider/pending-approval/cancel flows | 415 | - 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 |
| 418 | 417 | ||
| 419 | --- | 418 | --- |
| 420 | 419 | ||
| @@ -3,7 +3,9 @@ · 2 unmodified lines | |||
| 3 | Convex remote server app scaffold for Executor v2. | 3 | Convex remote server app scaffold for Executor v2. |
| 4 | 4 | ||
| 5 | Current scaffold includes: | 5 | Current scaffold includes: |
| 6 | Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex` | 6 | Convex schema wiring lives directly in `convex/schema.ts` |
| 7 | MCP HTTP endpoint at `convex/http.ts` and `convex/mcp.ts`, wired through a run client | 7 | MCP HTTP endpoint at `GET/POST/DELETE /v1/mcp` via `convex/http.ts` and `convex/mcp.ts` |
| 8 | runtime callback endpoints at `POST /runtime/tool-call` and `POST /v1/runtime/tool-call` | 8 | runtime callback endpoint at `POST /v1/runtime/tool-call` |
| 9 | action-level callback handler scaffold in `convex/runtimeCallbacks.ts` | 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` | ||
| @@ -8,10 +8,14 @@ · 7 unmodified lines | |||
| 8 | * @module | 8 | * @module |
| 9 | */ | 9 | */ |
| 10 | 10 | ||
| 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"; | ||
| 11 | import type * as executor from "../executor.js"; | 16 | import type * as executor from "../executor.js"; |
| 12 | import type * as http from "../http.js"; | 17 | import type * as http from "../http.js"; |
| 13 | import type * as mcp from "../mcp.js"; | 18 | import type * as mcp from "../mcp.js"; |
| 14 | import type * as rpc_exit from "../rpc_exit.js"; | ||
| 15 | import type * as run_executor from "../run_executor.js"; | 19 | import type * as run_executor from "../run_executor.js"; |
| 16 | import type * as runtimeCallbacks from "../runtimeCallbacks.js"; | 20 | import type * as runtimeCallbacks from "../runtimeCallbacks.js"; |
| 17 | import type * as runtime_adapter from "../runtime_adapter.js"; | 21 | import type * as runtime_adapter from "../runtime_adapter.js"; |
| @@ -23,10 +27,14 @@ import type { · 5 unmodified lines | |||
| 23 | } from "convex/server"; | 27 | } from "convex/server"; |
| 24 | 28 | ||
| 25 | declare const fullApi: ApiFromModules<{ | 29 | 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; | ||
| 26 | executor: typeof executor; | 35 | executor: typeof executor; |
| 27 | http: typeof http; | 36 | http: typeof http; |
| 28 | mcp: typeof mcp; | 37 | mcp: typeof mcp; |
| 29 | rpc_exit: typeof rpc_exit; | ||
| 30 | run_executor: typeof run_executor; | 38 | run_executor: typeof run_executor; |
| 31 | runtimeCallbacks: typeof runtimeCallbacks; | 39 | runtimeCallbacks: typeof runtimeCallbacks; |
| 32 | runtime_adapter: typeof runtime_adapter; | 40 | runtime_adapter: typeof runtime_adapter; |
| @@ -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; | ||
| @@ -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 | }; | ||
| @@ -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 | ); | ||
| @@ -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 | }); | ||
| @@ -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 | }); | ||
| @@ -3,7 +3,7 @@ import { convexTest } from "convex-test"; · 2 unmodified lines | |||
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | ||
| 5 | import { api } from "./_generated/api"; | 5 | import { api } from "./_generated/api"; |
| 6 | import { unwrapRpcSuccess } from "./rpc_exit"; | 6 | import { executeRunImpl } from "./executor"; |
| 7 | import schema from "./schema"; | 7 | import schema from "./schema"; |
| 8 | 8 | ||
| 9 | const setup = () => | 9 | const setup = () => |
| @@ -12,54 +12,80 @@ const setup = () => · 2 unmodified lines | |||
| 12 | "./mcp.ts": () => import("./mcp"), | 12 | "./mcp.ts": () => import("./mcp"), |
| 13 | "./executor.ts": () => import("./executor"), | 13 | "./executor.ts": () => import("./executor"), |
| 14 | "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"), | 14 | "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"), |
| 15 | "./controlPlane.ts": () => import("./controlPlane"), | ||
| 15 | "./_generated/api.js": () => import("./_generated/api.js"), | 16 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 16 | }); | 17 | }); |
| 17 | 18 | ||
| 18 | describe("Convex executor actions", () => { | 19 | describe("Convex executor and control-plane", () => { |
| 19 | it.effect("executes code via executeRun action", () => | 20 | it.effect("executes code via executeRunImpl", () => |
| 20 | Effect.gen(function* () { | 21 | Effect.gen(function* () { |
| 21 | const t = setup(); | 22 | const result = yield* executeRunImpl({ |
| 22 | 23 | code: "return 6 * 7;", | |
| 23 | const result = unwrapRpcSuccess<{ | 24 | }); |
| 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 | ); | ||
| 34 | 25 | ||
| 35 | expect(result.status).toBe("completed"); | 26 | expect(result.status).toBe("completed"); |
| 36 | expect(result.result).toBe(42); | 27 | expect(result.result).toBe(42); |
| 37 | }), | 28 | }), |
| 38 | ); | 29 | ); |
| 39 | 30 | ||
| 40 | it.effect("returns failed callback result from runtime callback action", () => | 31 | it.effect("upserts, lists, and removes sources", () => |
| 41 | Effect.gen(function* () { | 32 | Effect.gen(function* () { |
| 42 | const t = setup(); | 33 | const t = setup(); |
| 43 | 34 | ||
| 44 | const result = unwrapRpcSuccess<{ | 35 | const added = (yield* Effect.tryPromise(() => |
| 45 | ok: boolean; | 36 | t.mutation(api.controlPlane.upsertSource, { |
| 46 | kind?: string; | 37 | workspaceId: "ws_1", |
| 47 | error?: string; | 38 | payload: { |
| 48 | }>( | 39 | id: "src_1", |
| 49 | yield* Effect.tryPromise(() => | 40 | name: "Weather", |
| 50 | t.action(api.runtimeCallbacks.handleToolCall, { | 41 | kind: "openapi", |
| 51 | runId: "run_1", | 42 | endpoint: "https://example.com/openapi.json", |
| 52 | callId: "call_1", | 43 | enabled: true, |
| 53 | toolPath: "tools.example.lookup", | 44 | configJson: "{}", |
| 54 | input: { query: "ping" }, | 45 | status: "draft", |
| 55 | }), | 46 | sourceHash: null, |
| 56 | ), | 47 | lastError: null, |
| 57 | "runtimeCallbacks.handleToolCall", | 48 | }, |
| 58 | ); | 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>; | ||
| 59 | 87 | ||
| 60 | expect(result.ok).toBe(false); | 88 | expect(listedAfterRemove).toHaveLength(0); |
| 61 | expect(result.kind).toBe("failed"); | ||
| 62 | expect(result.error).toContain("tools.example.lookup"); | ||
| 63 | }), | 89 | }), |
| 64 | ); | 90 | ); |
| 65 | }); | 91 | }); |
| @@ -1,9 +1,6 @@ | |||
| 1 | import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc"; | 1 | import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk"; |
| 2 | import { executorConfectSchema } from "@executor-v2/persistence-convex"; | ||
| 3 | import type { ExecuteRunInput } from "@executor-v2/sdk"; | ||
| 4 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | 4 | ||
| 8 | import { | 5 | import { |
| 9 | ConvexRunExecutor, | 6 | ConvexRunExecutor, |
| @@ -11,24 +8,6 @@ import { · 1 unmodified line | |||
| 11 | ConvexToolProviderRegistryLive, | 8 | ConvexToolProviderRegistryLive, |
| 12 | } from "./run_executor"; | 9 | } from "./run_executor"; |
| 13 | 10 | ||
| 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 | |||
| 32 | const ConvexExecuteDependenciesLive = Layer.merge( | 11 | const ConvexExecuteDependenciesLive = Layer.merge( |
| 33 | ConvexRunExecutorLive, | 12 | ConvexRunExecutorLive, |
| 34 | ConvexToolProviderRegistryLive, | 13 | ConvexToolProviderRegistryLive, |
| @@ -36,29 +15,8 @@ const ConvexExecuteDependenciesLive = Layer.merge( · 1 unmodified line | |||
| 36 | 15 | ||
| 37 | export const executeRunImpl = ( | 16 | export const executeRunImpl = ( |
| 38 | input: ExecuteRunInput, | 17 | input: ExecuteRunInput, |
| 39 | ): Effect.Effect<typeof executeRunResultSchema.Type> => | 18 | ): Effect.Effect<ExecuteRunResult> => |
| 40 | Effect.gen(function* () { | 19 | Effect.gen(function* () { |
| 41 | const runExecutor = yield* ConvexRunExecutor; | 20 | const runExecutor = yield* ConvexRunExecutor; |
| 42 | return yield* runExecutor.executeRun(input); | 21 | return yield* runExecutor.executeRun(input); |
| 43 | }).pipe(Effect.provide(ConvexExecuteDependenciesLive)); | 22 | }).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; | ||
| @@ -1,24 +1,48 @@ | |||
| 1 | import { httpRouter } from "convex/server"; | 1 | import { httpRouter } from "convex/server"; |
| 2 | |||
| 3 | import { controlPlaneHttpHandler } from "./controlPlane"; | ||
| 2 | import { mcpHandler } from "./mcp"; | 4 | import { mcpHandler } from "./mcp"; |
| 3 | import { handleToolCallHttp } from "./runtimeCallbacks"; | 5 | import { handleToolCallHttp } from "./runtimeCallbacks"; |
| 4 | 6 | ||
| 5 | const http = httpRouter(); | 7 | const http = httpRouter(); |
| 6 | 8 | ||
| 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 }); | ||
| 10 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); | 9 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); |
| 11 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); | 10 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); |
| 12 | http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); | 11 | http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); |
| 13 | http.route({ | 12 | http.route({ |
| 14 | path: "/runtime/tool-call", | 13 | path: "/v1/runtime/tool-call", |
| 15 | method: "POST", | 14 | method: "POST", |
| 16 | handler: handleToolCallHttp, | 15 | handler: handleToolCallHttp, |
| 17 | }); | 16 | }); |
| 18 | http.route({ | 17 | http.route({ |
| 19 | path: "/v1/runtime/tool-call", | 18 | pathPrefix: "/v1/", |
| 19 | method: "GET", | ||
| 20 | handler: controlPlaneHttpHandler, | ||
| 21 | }); | ||
| 22 | http.route({ | ||
| 23 | pathPrefix: "/v1/", | ||
| 20 | method: "POST", | 24 | 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, | ||
| 22 | }); | 46 | }); |
| 23 | 47 | ||
| 24 | export default http; | 48 | export default http; |
| @@ -1,13 +1,13 @@ | |||
| 1 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | 1 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; |
| 2 | import { createExecutorRunClient } from "@executor-v2/sdk"; | 2 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 3 | import { api } from "./_generated/api"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | |||
| 4 | import { httpAction } from "./_generated/server"; | 5 | import { httpAction } from "./_generated/server"; |
| 5 | import { unwrapRpcSuccess } from "./rpc_exit"; | 6 | import { executeRunImpl } from "./executor"; |
| 6 | export const mcpHandler = httpAction(async (ctx, request) => { | 7 | |
| 8 | export const mcpHandler = httpAction(async (_ctx, request) => { | ||
| 7 | const runClient = createExecutorRunClient((input) => | 9 | const runClient = createExecutorRunClient((input) => |
| 8 | ctx | 10 | Effect.runPromise(executeRunImpl(input)), |
| 9 | .runAction(api.executor.executeRun, input) | ||
| 10 | .then((result) => unwrapRpcSuccess(result, "executor.executeRun")), | ||
| 11 | ); | 11 | ); |
| 12 | 12 | ||
| 13 | return handleMcpHttpRequest(request, { | 13 | return handleMcpHttpRequest(request, { |
| @@ -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 | }; | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | import { | 1 | import { |
| 2 | makeLocalInProcessRuntimeAdapter, | ||
| 2 | makeToolProviderRegistry, | 3 | makeToolProviderRegistry, |
| 3 | ToolProviderRegistryService, | 4 | ToolProviderRegistryService, |
| 4 | } from "@executor-v2/engine/tool-providers"; | 5 | type RuntimeExecuteError, |
| 6 | } from "@executor-v2/engine"; | ||
| 5 | import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk"; | 7 | import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk"; |
| 6 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 7 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 9 | 11 | ||
| 10 | import { makeLocalInProcessRuntimeAdapter } from "./runtime_adapter"; | ||
| 11 | |||
| 12 | export type ConvexRunExecutorService = { | 12 | export type ConvexRunExecutorService = { |
| 13 | executeRun: ( | 13 | executeRun: ( |
| 14 | input: ExecuteRunInput, | 14 | input: ExecuteRunInput, |
| @@ -21,21 +21,16 @@ export class ConvexRunExecutor extends Context.Tag( · 6 unmodified lines | |||
| 21 | 21 | ||
| 22 | const runtimeAdapter = makeLocalInProcessRuntimeAdapter(); | 22 | const runtimeAdapter = makeLocalInProcessRuntimeAdapter(); |
| 23 | 23 | ||
| 24 | const errorToText = (error: unknown): string => { | 24 | const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => { |
| 25 | if (error instanceof Error) { | 25 | switch (error._tag) { |
| 26 | return error.message; | 26 | case "RuntimeAdapterError": |
| 27 | } | 27 | case "LocalCodeRunnerError": |
| 28 | 28 | case "DenoSubprocessRunnerError": | |
| 29 | if ( | 29 | case "ToolProviderError": |
| 30 | typeof error === "object" && | 30 | return error.details ? `${error.message}: ${error.details}` : error.message; |
| 31 | error !== null && | 31 | case "ToolProviderRegistryError": |
| 32 | "message" in error && | 32 | return error.message; |
| 33 | typeof error.message === "string" | ||
| 34 | ) { | ||
| 35 | return error.message; | ||
| 36 | } | 33 | } |
| 37 | |||
| 38 | return String(error); | ||
| 39 | }; | 34 | }; |
| 40 | 35 | ||
| 41 | const executeRun = Effect.fn( | 36 | const executeRun = Effect.fn( |
| @@ -59,21 +54,21 @@ const executeRun = Effect.fn( · 17 unmodified lines | |||
| 59 | tools: [], | 54 | tools: [], |
| 60 | }) | 55 | }) |
| 61 | .pipe( | 56 | .pipe( |
| 62 | Effect.map( | 57 | Effect.map( |
| 63 | (result): ExecuteRunResult => ({ | 58 | (result): ExecuteRunResult => ({ |
| 64 | runId, | 59 | runId, |
| 65 | status: "completed", | 60 | status: "completed", |
| 66 | result, | 61 | result, |
| 67 | }), | 62 | }), |
| 68 | ), | 63 | ), |
| 69 | Effect.catchAll((error) => | 64 | Effect.catchAll((error) => |
| 70 | Effect.succeed({ | 65 | Effect.succeed({ |
| 71 | runId, | 66 | runId, |
| 72 | status: "failed", | 67 | status: "failed", |
| 73 | error: errorToText(error), | 68 | error: formatRuntimeExecuteError(error), |
| 74 | } satisfies ExecuteRunResult), | 69 | } satisfies ExecuteRunResult), |
| 75 | ), | 70 | ), |
| 76 | ); | 71 | ); |
| 77 | }); | 72 | }); |
| 78 | 73 | ||
| 79 | export const ConvexRunExecutorLive = Layer.succeed( | 74 | export const ConvexRunExecutorLive = Layer.succeed( |
| @@ -1,27 +1,13 @@ | |||
| 1 | import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc"; | ||
| 2 | import { executorConfectSchema } from "@executor-v2/persistence-convex"; | ||
| 3 | import type { | 1 | import type { |
| 4 | RuntimeToolCallRequest, | 2 | RuntimeToolCallRequest, |
| 5 | RuntimeToolCallResult, | 3 | RuntimeToolCallResult, |
| 6 | } from "@executor-v2/sdk"; | 4 | } from "@executor-v2/sdk"; |
| 5 | import * as Data from "effect/Data"; | ||
| 7 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as ParseResult from "effect/ParseResult"; | ||
| 8 | import * as Schema from "effect/Schema"; | 8 | 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 | }); | ||
| 21 | 9 | ||
| 22 | const factory = createRpcFactory({ | 10 | import { httpAction } from "./_generated/server"; |
| 23 | schema: executorConfectSchema, | ||
| 24 | }); | ||
| 25 | 11 | ||
| 26 | export const handleToolCallImpl = ( | 12 | export const handleToolCallImpl = ( |
| 27 | input: RuntimeToolCallRequest, | 13 | input: RuntimeToolCallRequest, |
| @@ -32,35 +18,26 @@ export const handleToolCallImpl = ( · 4 unmodified lines | |||
| 32 | error: `Convex runtime callback received tool '${input.toolPath}', but callback invocation is not wired yet.`, | 18 | error: `Convex runtime callback received tool '${input.toolPath}', but callback invocation is not wired yet.`, |
| 33 | }); | 19 | }); |
| 34 | 20 | ||
| 35 | const handleToolCallEndpoint = factory.action({ | 21 | class RuntimeToolCallBadRequestError extends Data.TaggedError( |
| 36 | payload: { | 22 | "RuntimeToolCallBadRequestError", |
| 37 | runId: Schema.String, | 23 | )<{ |
| 38 | callId: Schema.String, | 24 | message: string; |
| 39 | toolPath: Schema.String, | 25 | details: string; |
| 40 | input: Schema.optional( | 26 | }> {} |
| 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 | ); | ||
| 58 | 27 | ||
| 59 | const runtimeCallbacksRpc = makeRpcModule({ | 28 | const RuntimeToolCallRequestSchema = Schema.Struct({ |
| 60 | handleToolCall: handleToolCallEndpoint, | 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 | ), | ||
| 61 | }); | 38 | }); |
| 62 | 39 | ||
| 63 | export const handleToolCall = runtimeCallbacksRpc.handlers.handleToolCall; | 40 | const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema); |
| 64 | 41 | ||
| 65 | const badRequest = (message: string): Response => | 42 | const badRequest = (message: string): Response => |
| 66 | Response.json( | 43 | Response.json( |
| @@ -72,48 +49,42 @@ const badRequest = (message: string): Response => · 5 unmodified lines | |||
| 72 | { status: 400 }, | 49 | { status: 400 }, |
| 73 | ); | 50 | ); |
| 74 | 51 | ||
| 75 | export const handleToolCallHttp = httpAction(async (ctx, request) => { | 52 | const formatBadRequestMessage = (error: RuntimeToolCallBadRequestError): string => |
| 76 | let body: unknown; | 53 | error.details.length > 0 ? `${error.message}: ${error.details}` : error.message; |
| 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 | } | ||
| 90 | 54 | ||
| 91 | const payload = body as Partial<RuntimeToolCallRequest>; | 55 | const formatUnknownDetails = (cause: unknown): string => String(cause); |
| 92 | 56 | ||
| 93 | if ( | 57 | const handleToolCallHttpEffect = ( |
| 94 | typeof payload.runId !== "string" || | 58 | request: Request, |
| 95 | typeof payload.callId !== "string" || | 59 | ): Effect.Effect<Response, never> => |
| 96 | typeof payload.toolPath !== "string" | 60 | Effect.gen(function* () { |
| 97 | ) { | 61 | const body = yield* Effect.tryPromise({ |
| 98 | return badRequest("Runtime callback request body is missing required fields"); | 62 | try: () => request.json(), |
| 99 | } | 63 | catch: (cause) => |
| 64 | new RuntimeToolCallBadRequestError({ | ||
| 65 | message: "Invalid runtime callback request body", | ||
| 66 | details: formatUnknownDetails(cause), | ||
| 67 | }), | ||
| 68 | }); | ||
| 100 | 69 | ||
| 101 | let result: RuntimeToolCallResult; | 70 | const input = yield* decodeRuntimeToolCallRequest(body).pipe( |
| 102 | try { | 71 | Effect.mapError( |
| 103 | result = unwrapRpcSuccess( | 72 | (cause) => |
| 104 | await ctx.runAction(api.runtimeCallbacks.handleToolCall, { | 73 | new RuntimeToolCallBadRequestError({ |
| 105 | runId: payload.runId, | 74 | message: "Runtime callback request body is invalid", |
| 106 | callId: payload.callId, | 75 | details: ParseResult.TreeFormatter.formatErrorSync(cause), |
| 107 | toolPath: payload.toolPath, | 76 | }), |
| 108 | input: | 77 | ), |
| 109 | payload.input && typeof payload.input === "object" | ||
| 110 | ? (payload.input as Record<string, unknown>) | ||
| 111 | : undefined, | ||
| 112 | }), | ||
| 113 | "runtimeCallbacks.handleToolCall", | ||
| 114 | ); | 78 | ); |
| 115 | } catch (cause) { | 79 | |
| 116 | return badRequest(cause instanceof Error ? cause.message : String(cause)); | 80 | const result = yield* handleToolCallImpl(input); |
| 117 | } | 81 | return Response.json(result, { status: 200 }); |
| 118 | return Response.json(result, { status: 200 }); | 82 | }).pipe( |
| 119 | }); | 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 | ); | ||
| @@ -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>; | ||
| 2 | 57 | ||
| 3 | export default executorConfectSchema.convexSchemaDefinition; | 58 | export default executorConfectSchema.convexSchemaDefinition; |
| @@ -10,9 +10,10 @@ · 9 unmodified lines | |||
| 10 | }, | 10 | }, |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@executor-v2/confect": "workspace:*", | 12 | "@executor-v2/confect": "workspace:*", |
| 13 | "@executor-v2/control-plane": "workspace:*", | ||
| 13 | "@executor-v2/engine": "workspace:*", | 14 | "@executor-v2/engine": "workspace:*", |
| 14 | "@executor-v2/mcp-gateway": "workspace:*", | 15 | "@executor-v2/mcp-gateway": "workspace:*", |
| 15 | "@executor-v2/persistence-convex": "workspace:*", | 16 | "@executor-v2/schema": "workspace:*", |
| 16 | "@executor-v2/sdk": "workspace:*", | 17 | "@executor-v2/sdk": "workspace:*", |
| 17 | "convex": "catalog:", | 18 | "convex": "catalog:", |
| 18 | "effect": "catalog:" | 19 | "effect": "catalog:" |
| @@ -3,11 +3,14 @@ · 2 unmodified lines | |||
| 3 | Local Process Manager app scaffold for Executor v2. | 3 | Local Process Manager app scaffold for Executor v2. |
| 4 | 4 | ||
| 5 | Current scaffold includes: | 5 | Current scaffold includes: |
| 6 | - local MCP endpoint at `POST /mcp` | 6 | - local MCP endpoint at `GET/POST/DELETE /v1/mcp` |
| 7 | - health endpoint at `GET /healthz` | 7 | - health endpoint at `GET /healthz` |
| 8 | - MCP tool routing via `@executor-v2/mcp-gateway` | 8 | - MCP tool routing via `@executor-v2/mcp-gateway` |
| 9 | - local runtime adapter injection (`makeLocalInProcessRuntimeAdapter()` in entry wiring) | 9 | - 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` | ||
| 11 | 14 | ||
| 12 | App wiring is now split by responsibility: | 15 | App wiring is now split by responsibility: |
| 13 | - `src/config.ts`: Effect config service (`PORT`) | 16 | - `src/config.ts`: Effect config service (`PORT`) |
| @@ -11,9 +11,11 @@ · 10 unmodified lines | |||
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@effect/platform": "catalog:", | 12 | "@effect/platform": "catalog:", |
| 13 | "@effect/platform-bun": "^0.86.0", | 13 | "@effect/platform-bun": "^0.86.0", |
| 14 | "@executor-v2/control-plane": "workspace:*", | ||
| 14 | "@executor-v2/engine": "workspace:*", | 15 | "@executor-v2/engine": "workspace:*", |
| 15 | "@executor-v2/mcp-gateway": "workspace:*", | 16 | "@executor-v2/mcp-gateway": "workspace:*", |
| 16 | "@executor-v2/sdk": "workspace:*", | 17 | "@executor-v2/sdk": "workspace:*", |
| 18 | "@executor-v2/persistence-local": "workspace:*", | ||
| 17 | "effect": "catalog:" | 19 | "effect": "catalog:" |
| 18 | }, | 20 | }, |
| 19 | "devDependencies": { | 21 | "devDependencies": { |
| @@ -109,7 +109,7 @@ const withMcpClient = (port: number) => · 108 unmodified lines | |||
| 109 | Effect.tryPromise({ | 109 | Effect.tryPromise({ |
| 110 | try: async () => { | 110 | try: async () => { |
| 111 | const transport = new StreamableHTTPClientTransport( | 111 | 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`), |
| 113 | ); | 113 | ); |
| 114 | const client = new Client( | 114 | const client = new Client( |
| 115 | { name: "executor-v2-pm-e2e", version: "0.0.0" }, | 115 | { name: "executor-v2-pm-e2e", version: "0.0.0" }, |
| @@ -5,6 +5,11 @@ import { · 4 unmodified lines | |||
| 5 | HttpServerResponse, | 5 | HttpServerResponse, |
| 6 | } from "@effect/platform"; | 6 | } from "@effect/platform"; |
| 7 | import { BunHttpServer, BunHttpServerRequest } from "@effect/platform-bun"; | 7 | import { BunHttpServer, BunHttpServerRequest } from "@effect/platform-bun"; |
| 8 | import { | ||
| 9 | ControlPlaneService, | ||
| 10 | controlPlaneOpenApiSpec, | ||
| 11 | makeControlPlaneWebHandler, | ||
| 12 | } from "@executor-v2/control-plane"; | ||
| 8 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 10 | 15 | ||
| @@ -25,17 +30,33 @@ const fromWebHandler = (handler: (request: Request) => Promise<Response>) => · 14 unmodified lines | |||
| 25 | export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () { | 30 | export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () { |
| 26 | const { port } = yield* PmConfig; | 31 | const { port } = yield* PmConfig; |
| 27 | const { handleMcp } = yield* PmMcpHandler; | 32 | 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 | ); | ||
| 28 | 43 | ||
| 29 | const httpLive = HttpRouter.empty.pipe( | 44 | const httpLive = HttpRouter.empty.pipe( |
| 30 | HttpRouter.get("/healthz", HttpServerResponse.json({ ok: true, service: "pm" })), | 45 | 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)), | ||
| 34 | HttpRouter.get("/v1/mcp", fromWebHandler(handleMcp)), | 46 | HttpRouter.get("/v1/mcp", fromWebHandler(handleMcp)), |
| 35 | HttpRouter.post("/v1/mcp", fromWebHandler(handleMcp)), | 47 | HttpRouter.post("/v1/mcp", fromWebHandler(handleMcp)), |
| 36 | HttpRouter.del("/v1/mcp", fromWebHandler(handleMcp)), | 48 | HttpRouter.del("/v1/mcp", fromWebHandler(handleMcp)), |
| 37 | HttpRouter.post("/runtime/tool-call", handleToolCallHttp), | ||
| 38 | HttpRouter.post("/v1/runtime/tool-call", handleToolCallHttp), | 49 | 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 | ), | ||
| 39 | HttpServer.serve(), | 60 | HttpServer.serve(), |
| 40 | HttpServer.withLogAddress, | 61 | HttpServer.withLogAddress, |
| 41 | Layer.provide(BunHttpServer.layer({ port })), | 62 | Layer.provide(BunHttpServer.layer({ port })), |
| @@ -1,4 +1,7 @@ | |||
| 1 | import * as BunContext from "@effect/platform-bun/BunContext"; | ||
| 2 | import { ControlPlaneServiceLive } from "@executor-v2/control-plane"; | ||
| 1 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/engine"; | 3 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/engine"; |
| 4 | import { LocalSourceStoreLive } from "@executor-v2/persistence-local"; | ||
| 2 | 5 | ||
| 3 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| @@ -18,12 +21,21 @@ const PmMcpDependenciesLive = Layer.merge( · 13 unmodified lines | |||
| 18 | PmToolProviderRegistryLive, | 21 | PmToolProviderRegistryLive, |
| 19 | ); | 22 | ); |
| 20 | 23 | ||
| 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 | |||
| 21 | const PmAppLive = Layer.mergeAll( | 32 | const PmAppLive = Layer.mergeAll( |
| 22 | PmConfigLive, | 33 | PmConfigLive, |
| 23 | PmMcpHandlerLive.pipe(Layer.provide(PmMcpDependenciesLive)), | 34 | PmMcpHandlerLive.pipe(Layer.provide(PmMcpDependenciesLive)), |
| 24 | PmToolCallHandlerLive, | 35 | PmToolCallHandlerLive, |
| 36 | PmControlPlaneDependenciesLive, | ||
| 25 | ); | 37 | ); |
| 26 | 38 | ||
| 27 | const program = startPmHttpServer().pipe(Effect.provide(PmAppLive)); | 39 | const program = Effect.scoped(startPmHttpServer()).pipe(Effect.provide(PmAppLive)); |
| 28 | 40 | ||
| 29 | await Effect.runPromise(program); | 41 | await Effect.runPromise(program); |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | |||
| 2 | makeToolProviderRegistry, | 2 | makeToolProviderRegistry, |
| 3 | ToolProviderRegistryService, | 3 | ToolProviderRegistryService, |
| 4 | type RuntimeAdapter, | 4 | type RuntimeAdapter, |
| 5 | type RuntimeExecuteError, | ||
| 5 | } from "@executor-v2/engine"; | 6 | } from "@executor-v2/engine"; |
| 6 | import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk"; | 7 | import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk"; |
| 7 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| @@ -19,21 +20,16 @@ export class PmRunExecutor extends Context.Tag("@executor-v2/app-pm/PmRunExecuto · 11 unmodified lines | |||
| 19 | PmRunExecutorService | 20 | PmRunExecutorService |
| 20 | >() {} | 21 | >() {} |
| 21 | 22 | ||
| 22 | const errorToText = (error: unknown): string => { | 23 | const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => { |
| 23 | if (error instanceof Error) { | 24 | switch (error._tag) { |
| 24 | return error.message; | 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; | ||
| 25 | } | 32 | } |
| 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); | ||
| 37 | }; | 33 | }; |
| 38 | 34 | ||
| 39 | const makeExecuteRun = (runtimeAdapter: RuntimeAdapter) => | 35 | const makeExecuteRun = (runtimeAdapter: RuntimeAdapter) => |
| @@ -69,7 +65,7 @@ const makeExecuteRun = (runtimeAdapter: RuntimeAdapter) => · 29 unmodified lines | |||
| 69 | Effect.succeed({ | 65 | Effect.succeed({ |
| 70 | runId, | 66 | runId, |
| 71 | status: "failed", | 67 | status: "failed", |
| 72 | error: errorToText(error), | 68 | error: formatRuntimeExecuteError(error), |
| 73 | } satisfies ExecuteRunResult), | 69 | } satisfies ExecuteRunResult), |
| 74 | ), | 70 | ), |
| 75 | ); | 71 | ); |
| @@ -7,6 +7,7 @@ import * as Context from "effect/Context"; · 6 unmodified lines | |||
| 7 | import * as Data from "effect/Data"; | 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as ParseResult from "effect/ParseResult"; | ||
| 10 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 11 | 12 | ||
| 12 | export type PmToolCallHandlerService = { | 13 | export type PmToolCallHandlerService = { |
| @@ -37,29 +38,25 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 24 unmodified lines | |||
| 37 | 38 | ||
| 38 | const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema); | 39 | const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema); |
| 39 | 40 | ||
| 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 | |||
| 57 | const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError => | 41 | const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError => |
| 58 | new PmToolCallHttpRequestError({ | 42 | new PmToolCallHttpRequestError({ |
| 59 | message: "Invalid runtime callback request body", | 43 | message: "Invalid runtime callback request body", |
| 60 | details: cause instanceof Error ? cause.message : String(cause), | 44 | details: String(cause), |
| 61 | }); | 45 | }); |
| 62 | 46 | ||
| 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 | |||
| 63 | const handleToolCall = Effect.fn("@executor-v2/app-pm/tool-call.handle")(function* ( | 60 | const handleToolCall = Effect.fn("@executor-v2/app-pm/tool-call.handle")(function* ( |
| 64 | input: RuntimeToolCallRequest, | 61 | input: RuntimeToolCallRequest, |
| 65 | ) { | 62 | ) { |
| @@ -82,24 +79,26 @@ export const handleToolCallBody = Effect.fn( · 16 unmodified lines | |||
| 82 | )(function* (body: unknown) { | 79 | )(function* (body: unknown) { |
| 83 | const handler = yield* PmToolCallHandler; | 80 | const handler = yield* PmToolCallHandler; |
| 84 | const input = yield* decodeRuntimeToolCallRequest(body).pipe( | 81 | const input = yield* decodeRuntimeToolCallRequest(body).pipe( |
| 85 | Effect.mapError(decodeRequestBodyError), | 82 | Effect.mapError(decodeRequestPayloadError), |
| 86 | ); | 83 | ); |
| 87 | 84 | ||
| 88 | return yield* handler.handleToolCall(input); | 85 | return yield* handler.handleToolCall(input); |
| 89 | }); | 86 | }); |
| 90 | 87 | ||
| 91 | export const handleToolCallHttp = Effect.gen(function* () { | 88 | 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 | ); | ||
| 93 | const result = yield* handleToolCallBody(body); | 92 | const result = yield* handleToolCallBody(body); |
| 94 | 93 | ||
| 95 | return yield* HttpServerResponse.json(result, { status: 200 }); | 94 | return yield* HttpServerResponse.json(result, { status: 200 }); |
| 96 | }).pipe( | 95 | }).pipe( |
| 97 | Effect.catchAll((error) => | 96 | Effect.catchTag("PmToolCallHttpRequestError", (error) => |
| 98 | HttpServerResponse.json( | 97 | HttpServerResponse.json( |
| 99 | { | 98 | { |
| 100 | ok: false, | 99 | ok: false, |
| 101 | kind: "failed", | 100 | kind: "failed", |
| 102 | error: errorToText(error), | 101 | error: formatHttpRequestError(error), |
| 103 | } satisfies RuntimeToolCallResult, | 102 | } satisfies RuntimeToolCallResult, |
| 104 | { status: 400 }, | 103 | { status: 400 }, |
| 105 | ), | 104 | ), |
| @@ -21,9 +21,10 @@ · 20 unmodified lines | |||
| 21 | "name": "@executor-v2/app-convex", | 21 | "name": "@executor-v2/app-convex", |
| 22 | "dependencies": { | 22 | "dependencies": { |
| 23 | "@executor-v2/confect": "workspace:*", | 23 | "@executor-v2/confect": "workspace:*", |
| 24 | "@executor-v2/control-plane": "workspace:*", | ||
| 24 | "@executor-v2/engine": "workspace:*", | 25 | "@executor-v2/engine": "workspace:*", |
| 25 | "@executor-v2/mcp-gateway": "workspace:*", | 26 | "@executor-v2/mcp-gateway": "workspace:*", |
| 26 | "@executor-v2/persistence-convex": "workspace:*", | 27 | "@executor-v2/schema": "workspace:*", |
| 27 | "@executor-v2/sdk": "workspace:*", | 28 | "@executor-v2/sdk": "workspace:*", |
| 28 | "convex": "catalog:", | 29 | "convex": "catalog:", |
| 29 | "effect": "catalog:", | 30 | "effect": "catalog:", |
| @@ -39,8 +40,10 @@ · 9 unmodified lines | |||
| 39 | "dependencies": { | 40 | "dependencies": { |
| 40 | "@effect/platform": "catalog:", | 41 | "@effect/platform": "catalog:", |
| 41 | "@effect/platform-bun": "^0.86.0", | 42 | "@effect/platform-bun": "^0.86.0", |
| 43 | "@executor-v2/control-plane": "workspace:*", | ||
| 42 | "@executor-v2/engine": "workspace:*", | 44 | "@executor-v2/engine": "workspace:*", |
| 43 | "@executor-v2/mcp-gateway": "workspace:*", | 45 | "@executor-v2/mcp-gateway": "workspace:*", |
| 46 | "@executor-v2/persistence-local": "workspace:*", | ||
| 44 | "@executor-v2/sdk": "workspace:*", | 47 | "@executor-v2/sdk": "workspace:*", |
| 45 | "effect": "catalog:", | 48 | "effect": "catalog:", |
| 46 | }, | 49 | }, |
| @@ -87,6 +90,17 @@ · 40 unmodified lines | |||
| 87 | "vitest": "catalog:", | 90 | "vitest": "catalog:", |
| 88 | }, | 91 | }, |
| 89 | }, | 92 | }, |
| 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 | }, | ||
| 90 | "packages/domain": { | 104 | "packages/domain": { |
| 91 | "name": "@executor-v2/domain", | 105 | "name": "@executor-v2/domain", |
| 92 | }, | 106 | }, |
| @@ -114,9 +128,6 @@ · 21 unmodified lines | |||
| 114 | "packages/oauth": { | 128 | "packages/oauth": { |
| 115 | "name": "@executor-v2/oauth", | 129 | "name": "@executor-v2/oauth", |
| 116 | }, | 130 | }, |
| 117 | "packages/rpc": { | ||
| 118 | "name": "@executor-v2/rpc", | ||
| 119 | }, | ||
| 120 | "packages/schema": { | 131 | "packages/schema": { |
| 121 | "name": "@executor-v2/schema", | 132 | "name": "@executor-v2/schema", |
| 122 | "dependencies": { | 133 | "dependencies": { |
| @@ -148,15 +159,6 @@ · 25 unmodified lines | |||
| 148 | "effect": "catalog:", | 159 | "effect": "catalog:", |
| 149 | }, | 160 | }, |
| 150 | }, | 161 | }, |
| 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 | }, | ||
| 160 | "packages/state-local-file": { | 162 | "packages/state-local-file": { |
| 161 | "name": "@executor-v2/persistence-local", | 163 | "name": "@executor-v2/persistence-local", |
| 162 | "dependencies": { | 164 | "dependencies": { |
| @@ -300,6 +302,8 @@ · 137 unmodified lines | |||
| 300 | 302 | ||
| 301 | "@executor-v2/confect": ["@executor-v2/confect@workspace:packages/confect"], | 303 | "@executor-v2/confect": ["@executor-v2/confect@workspace:packages/confect"], |
| 302 | 304 | ||
| 305 | "@executor-v2/control-plane": ["@executor-v2/control-plane@workspace:packages/control-plane"], | ||
| 306 | |||
| 303 | "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"], | 307 | "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"], |
| 304 | 308 | ||
| 305 | "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"], | 309 | "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"], |
| @@ -308,14 +312,10 @@ · 2 unmodified lines | |||
| 308 | 312 | ||
| 309 | "@executor-v2/oauth": ["@executor-v2/oauth@workspace:packages/oauth"], | 313 | "@executor-v2/oauth": ["@executor-v2/oauth@workspace:packages/oauth"], |
| 310 | 314 | ||
| 311 | "@executor-v2/persistence-convex": ["@executor-v2/persistence-convex@workspace:packages/state-convex"], | ||
| 312 | |||
| 313 | "@executor-v2/persistence-local": ["@executor-v2/persistence-local@workspace:packages/state-local-file"], | 315 | "@executor-v2/persistence-local": ["@executor-v2/persistence-local@workspace:packages/state-local-file"], |
| 314 | 316 | ||
| 315 | "@executor-v2/persistence-ports": ["@executor-v2/persistence-ports@workspace:packages/state-contracts"], | 317 | "@executor-v2/persistence-ports": ["@executor-v2/persistence-ports@workspace:packages/state-contracts"], |
| 316 | 318 | ||
| 317 | "@executor-v2/rpc": ["@executor-v2/rpc@workspace:packages/rpc"], | ||
| 318 | |||
| 319 | "@executor-v2/schema": ["@executor-v2/schema@workspace:packages/schema"], | 319 | "@executor-v2/schema": ["@executor-v2/schema@workspace:packages/schema"], |
| 320 | 320 | ||
| 321 | "@executor-v2/sdk": ["@executor-v2/sdk@workspace:packages/sdk"], | 321 | "@executor-v2/sdk": ["@executor-v2/sdk@workspace:packages/sdk"], |
| @@ -1,5 +1,5 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-v2/persistence-convex", | 2 | "name": "@executor-v2/control-plane", |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| @@ -9,9 +9,11 @@ · 3 unmodified lines | |||
| 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | }, | 10 | }, |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@executor-v2/confect": "workspace:*", | 12 | "@effect-atom/atom": "^0.4.11", |
| 13 | "@executor-v2/schema": "workspace:*", | 13 | "@effect/platform": "catalog:", |
| 14 | "@executor-v2/persistence-ports": "workspace:*", | 14 | "@executor-v2/persistence-ports": "workspace:*", |
| 15 | "@executor-v2/schema": "workspace:*", | ||
| 16 | "@executor-v2/source-manager": "workspace:*", | ||
| 15 | "effect": "catalog:" | 17 | "effect": "catalog:" |
| 16 | } | 18 | } |
| 17 | } | 19 | } |
| @@ -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); | ||
| @@ -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 | }; | ||
| @@ -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 | ) {} | ||
| @@ -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 | ); | ||
| @@ -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"; | ||
| @@ -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)); | ||
| @@ -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") {} | ||
| @@ -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 | ); | ||
| @@ -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"; | ||
| @@ -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 | }); | ||
| @@ -1,4 +1,7 @@ | |||
| 1 | { | 1 | { |
| 2 | "extends": "../../tsconfig.json", | 2 | "extends": "../../tsconfig.json", |
| 3 | "compilerOptions": { | ||
| 4 | "types": ["node", "bun"] | ||
| 5 | }, | ||
| 3 | "include": ["src"] | 6 | "include": ["src"] |
| 4 | } | 7 | } |
| @@ -9,7 +9,11 @@ import { · 8 unmodified lines | |||
| 9 | type ToolProvider, | 9 | type ToolProvider, |
| 10 | type ToolProviderRegistry, | 10 | type ToolProviderRegistry, |
| 11 | } from "./tool-providers"; | 11 | } from "./tool-providers"; |
| 12 | import type { RuntimeAdapter, RuntimeRunnableTool } from "./runtime-adapters"; | 12 | import type { |
| 13 | RuntimeAdapter, | ||
| 14 | RuntimeExecuteError, | ||
| 15 | RuntimeRunnableTool, | ||
| 16 | } from "./runtime-adapters"; | ||
| 13 | 17 | ||
| 14 | export type RuntimeRunClientExecuteInput = { | 18 | export type RuntimeRunClientExecuteInput = { |
| 15 | code: string; | 19 | code: string; |
| @@ -57,21 +61,16 @@ export type CreateInMemoryRuntimeRunClientOptions = { · 41 unmodified lines | |||
| 57 | makeRunId?: () => string; | 61 | makeRunId?: () => string; |
| 58 | }; | 62 | }; |
| 59 | 63 | ||
| 60 | const errorToText = (error: unknown): string => { | 64 | const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => { |
| 61 | if (error instanceof Error) { | 65 | switch (error._tag) { |
| 62 | return error.message; | 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; | ||
| 63 | } | 73 | } |
| 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); | ||
| 75 | }; | 74 | }; |
| 76 | 75 | ||
| 77 | const toInMemoryDescriptor = ( | 76 | const toInMemoryDescriptor = ( |
| @@ -127,7 +126,7 @@ const makeInMemoryToolProvider = ( · 49 unmodified lines | |||
| 127 | operation: "invoke", | 126 | operation: "invoke", |
| 128 | providerKind: "in_memory", | 127 | providerKind: "in_memory", |
| 129 | message: `In-memory tool invocation failed: ${input.tool.toolId}`, | 128 | message: `In-memory tool invocation failed: ${input.tool.toolId}`, |
| 130 | details: cause instanceof Error ? cause.message : String(cause), | 129 | details: String(cause), |
| 131 | }), | 130 | }), |
| 132 | }); | 131 | }); |
| 133 | }, | 132 | }, |
| @@ -152,7 +151,7 @@ export const createRuntimeRunClient = ( · 18 unmodified lines | |||
| 152 | return { | 151 | return { |
| 153 | runId, | 152 | runId, |
| 154 | status: "failed", | 153 | status: "failed", |
| 155 | error: errorToText(availabilityResult.left), | 154 | error: "Runtime availability check failed", |
| 156 | }; | 155 | }; |
| 157 | } | 156 | } |
| 158 | 157 | ||
| @@ -185,7 +184,7 @@ export const createRuntimeRunClient = ( · 26 unmodified lines | |||
| 185 | return { | 184 | return { |
| 186 | runId, | 185 | runId, |
| 187 | status: "failed", | 186 | status: "failed", |
| 188 | error: errorToText(executionResult.left), | 187 | error: formatRuntimeExecuteError(executionResult.left), |
| 189 | }; | 188 | }; |
| 190 | } | 189 | } |
| 191 | 190 | ||
| @@ -1,3 +0,0 @@ | |||
| 1 | # rpc | ||
| 2 | |||
| 3 | Effect RPC contract scaffold for Executor v2. | ||
| @@ -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 | } | ||
| @@ -1 +0,0 @@ | |||
| 1 | export {}; | ||
| @@ -3,7 +3,8 @@ · 2 unmodified lines | |||
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | ".": "./src/index.ts" | 6 | ".": "./src/index.ts", |
| 7 | "./source-catalog": "./src/source-catalog.ts" | ||
| 7 | }, | 8 | }, |
| 8 | "scripts": { | 9 | "scripts": { |
| 9 | "test": "bunx vitest run --no-watch src/*.test.ts", | 10 | "test": "bunx vitest run --no-watch src/*.test.ts", |
| @@ -5,3 +5,15 @@ export { · 4 unmodified lines | |||
| 5 | type RefreshOpenApiArtifactRequest, | 5 | type RefreshOpenApiArtifactRequest, |
| 6 | type SourceManagerService, | 6 | type SourceManagerService, |
| 7 | } from "./service"; | 7 | } 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"; | ||
| @@ -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 | ); | ||
| @@ -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` | ||
| @@ -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; | ||
| @@ -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"; | ||
| @@ -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 | }); | ||
| @@ -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>; | ||
| @@ -1,4 +0,0 @@ | |||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.json", | ||
| 3 | "include": ["src"] | ||
| 4 | } | ||