Showing the changes to notes (16 of 265 files). Show all
Unified · Split
| @@ -64,13 +64,7 @@ Do not make this workflow-specific. Model the generic thing as an artifact · 63 unmodified lines | ||
| 64 | 64 | project, with workflows as the first consumer. |
| 65 | 65 | |
| 66 | 66 | ```ts |
| 67 | −type ArtifactKind = | |
| 68 | − | "workflow" | |
| 69 | − | "component" | |
| 70 | − | "ui" | |
| 71 | − | "template" | |
| 72 | − | "connector" | |
| 73 | − | "document"; | |
| 67 | +type ArtifactKind = "workflow" | "component" | "ui" | "template" | "connector" | "document"; | |
| 74 | 68 | |
| 75 | 69 | type ArtifactProject = { |
| 76 | 70 | id: string; |
| @@ -171,18 +165,13 @@ export interface ArtifactStore { · 94 unmodified lines | ||
| 171 | 165 | input: CreateArtifactProjectInput, |
| 172 | 166 | ) => Effect.Effect<ArtifactProject, ArtifactError>; |
| 173 | 167 | |
| 174 | − readonly readFile: ( | |
| 175 | − ref: ArtifactRef, | |
| 176 | − path: string, | |
| 177 | − ) => Effect.Effect<Uint8Array, ArtifactError>; | |
| 168 | + readonly readFile: (ref: ArtifactRef, path: string) => Effect.Effect<Uint8Array, ArtifactError>; | |
| 178 | 169 | |
| 179 | 170 | readonly writeFiles: ( |
| 180 | 171 | input: WriteArtifactFilesInput, |
| 181 | 172 | ) => Effect.Effect<ArtifactCommit, ArtifactError>; |
| 182 | 173 | |
| 183 | − readonly diff: ( | |
| 184 | − input: ArtifactDiffInput, | |
| 185 | − ) => Effect.Effect<ArtifactDiff, ArtifactError>; | |
| 174 | + readonly diff: (input: ArtifactDiffInput) => Effect.Effect<ArtifactDiff, ArtifactError>; | |
| 186 | 175 | |
| 187 | 176 | readonly forkProject: ( |
| 188 | 177 | input: ForkArtifactProjectInput, |
| @@ -365,10 +354,7 @@ export interface ArtifactStore { · 176 unmodified lines | ||
| 365 | 354 | name: string; |
| 366 | 355 | }) => Effect.Effect<ArtifactProject, ArtifactError>; |
| 367 | 356 | |
| 368 | − readonly readFile: ( | |
| 369 | − ref: ArtifactRef, | |
| 370 | − path: string, | |
| 371 | − ) => Effect.Effect<Uint8Array, ArtifactError>; | |
| 357 | + readonly readFile: (ref: ArtifactRef, path: string) => Effect.Effect<Uint8Array, ArtifactError>; | |
| 372 | 358 | |
| 373 | 359 | readonly writeFiles: (input: { |
| 374 | 360 | projectId: string; |
| @@ -422,10 +408,7 @@ Feature plugin: · 47 unmodified lines | ||
| 422 | 408 | ```ts |
| 423 | 409 | // @executor/workflows |
| 424 | 410 | import { definePlugin, type PluginCtx } from "@executor/sdk"; |
| 425 | −import { | |
| 426 | − ArtifactStoreProtocol, | |
| 427 | − type ArtifactsProtocolCtx, | |
| 428 | −} from "@executor/artifacts"; | |
| 411 | +import { ArtifactStoreProtocol, type ArtifactsProtocolCtx } from "@executor/artifacts"; | |
| 429 | 412 | |
| 430 | 413 | type WorkflowCtx<TStore> = PluginCtx<TStore, ArtifactsProtocolCtx>; |
| 431 | 414 | |
| @@ -546,56 +529,47 @@ export async function summarizeTicket(input: SupportEmail) { · 114 unmodified lines | ||
| 546 | 529 | E2E flow: |
| 547 | 530 | |
| 548 | 531 | ```ts |
| 549 | −it.effect( | |
| 550 | − "creates a code-first workflow artifact and indexes a visual graph", | |
| 551 | − () => | |
| 552 | − Effect.gen(function* () { | |
| 553 | − const executor = yield* createExecutor({ | |
| 554 | − scopes: [testScope], | |
| 555 | − adapter, | |
| 556 | − blobs, | |
| 557 | − plugins: [ | |
| 558 | − artifactsLocalGitPlugin({ rootDir: tempDir }), | |
| 559 | − workflowsPlugin(), | |
| 560 | − ] as const, | |
| 561 | − protocols: { | |
| 562 | − "executor.artifacts.store": "artifacts-local-git", | |
| 532 | +it.effect("creates a code-first workflow artifact and indexes a visual graph", () => | |
| 533 | + Effect.gen(function* () { | |
| 534 | + const executor = yield* createExecutor({ | |
| 535 | + scopes: [testScope], | |
| 536 | + adapter, | |
| 537 | + blobs, | |
| 538 | + plugins: [artifactsLocalGitPlugin({ rootDir: tempDir }), workflowsPlugin()] as const, | |
| 539 | + protocols: { | |
| 540 | + "executor.artifacts.store": "artifacts-local-git", | |
| 541 | + }, | |
| 542 | + }); | |
| 543 | + | |
| 544 | + const created = yield* executor.workflows.create({ | |
| 545 | + name: "Support triage", | |
| 546 | + files: [ | |
| 547 | + { | |
| 548 | + type: "put", | |
| 549 | + path: "artifact.json", | |
| 550 | + contents: JSON.stringify({ | |
| 551 | + schemaVersion: 1, | |
| 552 | + kind: "workflow", | |
| 553 | + entrypoints: ["src/workflows/support-triage.workflow.ts"], | |
| 554 | + }), | |
| 555 | + }, | |
| 556 | + { | |
| 557 | + type: "put", | |
| 558 | + path: "src/workflows/support-triage.workflow.ts", | |
| 559 | + contents: supportTriageWorkflowSource, | |
| 563 | 560 | }, |
| 564 | − }); | |
| 565 | − | |
| 566 | − const created = yield* executor.workflows.create({ | |
| 567 | − name: "Support triage", | |
| 568 | − files: [ | |
| 569 | − { | |
| 570 | − type: "put", | |
| 571 | − path: "artifact.json", | |
| 572 | − contents: JSON.stringify({ | |
| 573 | − schemaVersion: 1, | |
| 574 | − kind: "workflow", | |
| 575 | − entrypoints: ["src/workflows/support-triage.workflow.ts"], | |
| 576 | − }), | |
| 577 | − }, | |
| 578 | − { | |
| 579 | − type: "put", | |
| 580 | − path: "src/workflows/support-triage.workflow.ts", | |
| 581 | − contents: supportTriageWorkflowSource, | |
| 582 | − }, | |
| 583 | − ], | |
| 584 | − }); | |
| 585 | − | |
| 586 | − const source = yield* executor.artifacts.readFile( | |
| 587 | − { projectId: created.project.id, ref: created.commit.id }, | |
| 588 | − "src/workflows/support-triage.workflow.ts", | |
| 589 | − ); | |
| 590 | − expect(new TextDecoder().decode(source)).toContain('"use workflow"'); | |
| 591 | − | |
| 592 | − expect(created.manifest.entrypoints).toEqual([ | |
| 593 | − "src/workflows/support-triage.workflow.ts", | |
| 594 | − ]); | |
| 595 | − expect(created.manifest.graph.nodes.map((node) => node.kind)).toContain( | |
| 596 | − "step", | |
| 597 | − ); | |
| 598 | − }), | |
| 561 | + ], | |
| 562 | + }); | |
| 563 | + | |
| 564 | + const source = yield* executor.artifacts.readFile( | |
| 565 | + { projectId: created.project.id, ref: created.commit.id }, | |
| 566 | + "src/workflows/support-triage.workflow.ts", | |
| 567 | + ); | |
| 568 | + expect(new TextDecoder().decode(source)).toContain('"use workflow"'); | |
| 569 | + | |
| 570 | + expect(created.manifest.entrypoints).toEqual(["src/workflows/support-triage.workflow.ts"]); | |
| 571 | + expect(created.manifest.graph.nodes.map((node) => node.kind)).toContain("step"); | |
| 572 | + }), | |
| 599 | 573 | ); |
| 600 | 574 | ``` |
| 601 | 575 | |
| @@ -89,7 +89,7 @@ Surveyed five plugin systems in `.reference/`: · 88 unmodified lines | ||
| 89 | 89 | - **emdash** — **closest match to our setup.** Astro/Vite + React, plugins are |
| 90 | 90 | npm packages with separate `./` and `./admin` exports, registered via |
| 91 | 91 | import-and-call in `astro.config.mjs`. `definePlugin({ id, hooks, routes, |
| 92 | − admin })` declarative shape. | |
| 92 | +admin })` declarative shape. | |
| 93 | 93 | - **dynamic-software** — most ambitious; Cloudflare Worker Loader for cloud |
| 94 | 94 | isolation, iframe + postMessage RPC for UI, Proxy-based typed API client. |
| 95 | 95 | |
| @@ -109,10 +109,10 @@ inference flows naturally; no codegen step. · 13 unmodified lines | ||
| 109 | 109 | |
| 110 | 110 | ```ts |
| 111 | 111 | // apps/local/executor.config.ts (after) |
| 112 | −import { defineExecutorConfig } from "@executor-js/sdk" | |
| 113 | −import { openApiPlugin } from "@executor-js/plugin-openapi" | |
| 114 | −import { mcpPlugin } from "@executor-js/plugin-mcp" | |
| 115 | −import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets" | |
| 112 | +import { defineExecutorConfig } from "@executor-js/sdk"; | |
| 113 | +import { openApiPlugin } from "@executor-js/plugin-openapi"; | |
| 114 | +import { mcpPlugin } from "@executor-js/plugin-mcp"; | |
| 115 | +import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | |
| 116 | 116 | |
| 117 | 117 | export default defineExecutorConfig({ |
| 118 | 118 | dialect: "sqlite", |
| @@ -121,7 +121,7 @@ export default defineExecutorConfig({ · 2 unmodified lines | ||
| 121 | 121 | mcpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 122 | 122 | fileSecretsPlugin(), |
| 123 | 123 | ] as const, |
| 124 | −}) | |
| 124 | +}); | |
| 125 | 125 | ``` |
| 126 | 126 | |
| 127 | 127 | "Dynamic" means npm-installable, not load-by-string-name. Same model as emdash. |
| @@ -137,12 +137,12 @@ config becomes a need. · 9 unmodified lines | ||
| 137 | 137 | "type": "module", |
| 138 | 138 | "exports": { |
| 139 | 139 | "./server": "./dist/server.js", |
| 140 | − "./client": "./dist/client.js" | |
| 140 | + "./client": "./dist/client.js", | |
| 141 | 141 | }, |
| 142 | 142 | "executor": { |
| 143 | 143 | "id": "foo", |
| 144 | − "version": "0.1.0" | |
| 145 | − } | |
| 144 | + "version": "0.1.0", | |
| 145 | + }, | |
| 146 | 146 | } |
| 147 | 147 | ``` |
| 148 | 148 | |
| @@ -171,7 +171,7 @@ Client side: separate primitive `defineClientPlugin` lives in · 22 unmodified lines | ||
| 171 | 171 | React types never leak into server bundles. |
| 172 | 172 | |
| 173 | 173 | **Layering — extension is the canonical SDK; routes/handlers is optional HTTP transport.** |
| 174 | −The HTTP layer is *not a peer* of the SDK; it's a transport over it. Plugin | |
| 174 | +The HTTP layer is _not a peer_ of the SDK; it's a transport over it. Plugin | |
| 175 | 175 | authors should treat extension as the implementation and write handlers as |
| 176 | 176 | thin wrappers that delegate via the `self` parameter (same pattern as |
| 177 | 177 | `staticSources(self) => [...]` already in the codebase). This keeps: |
| @@ -188,7 +188,7 @@ Three plugin shapes fall out of this layering: · 10 unmodified lines | ||
| 188 | 188 | utility for other plugins or scripts. CLI/embedded consumers use |
| 189 | 189 | `executor.<id>.method()`. Vite plugin notices no `./client` and skips the |
| 190 | 190 | plugin in the frontend bundle entirely. |
| 191 | −- **Both.** Extension *and* routes/handlers *and* a `./client`. Examples: | |
| 191 | +- **Both.** Extension _and_ routes/handlers _and_ a `./client`. Examples: | |
| 192 | 192 | openapi, mcp, anything that needs a frontend. Routes are thin wrappers |
| 193 | 193 | over extension methods. |
| 194 | 194 | - **HTTP-only.** Rare — webhook receivers, OAuth callback URLs. Routes |
| @@ -214,7 +214,7 @@ Plugin authors can write a complete plugin importing only from · 19 unmodified lines | ||
| 214 | 214 | `@executor-js/sdk` (server) and `@executor-js/sdk/client` (frontend). The SDK |
| 215 | 215 | re-exports `Schema`, `HttpApi`, `HttpApiGroup`, `HttpApiEndpoint`, |
| 216 | 216 | `HttpApiBuilder`, `Effect` so authors who don't want to dig into Effect |
| 217 | −don't have to. Authors who *do* want Effect-native code keep importing from | |
| 217 | +don't have to. Authors who _do_ want Effect-native code keep importing from | |
| 218 | 218 | `effect` directly. This keeps the door open without forcing the dependency. |
| 219 | 219 | |
| 220 | 220 | ### 6. Skip `capabilities` declaration for v1 |
| @@ -247,7 +247,7 @@ already handles this exact pattern for secrets and works fine: · 26 unmodified lines | ||
| 247 | 247 | secretProviders: (ctx) => [makeScopedProvider(...)] |
| 248 | 248 | ``` |
| 249 | 249 | |
| 250 | −Each new pluggable capability gets its *own* typed field on `PluginSpec`, | |
| 250 | +Each new pluggable capability gets its _own_ typed field on `PluginSpec`, | |
| 251 | 251 | same shape. When artifacts lands, that's `artifactStore: () => |
| 252 | 252 | ArtifactStore`. If connection providers want to be modeled this way, the |
| 253 | 253 | existing `connectionProviders` field already is. |
| @@ -335,24 +335,21 @@ server both import it: · 81 unmodified lines | ||
| 335 | 335 | |
| 336 | 336 | ```ts |
| 337 | 337 | // @executor-js/plugin-foo/src/shared.ts |
| 338 | −import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk" | |
| 338 | +import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk"; | |
| 339 | 339 | |
| 340 | 340 | export const Thing = Schema.Struct({ |
| 341 | 341 | id: Schema.String, |
| 342 | 342 | name: Schema.String, |
| 343 | −}) | |
| 343 | +}); | |
| 344 | 344 | |
| 345 | 345 | export const FooApi = HttpApiGroup.make("foo") |
| 346 | − .add( | |
| 347 | − HttpApiEndpoint.get("listThings")`/things` | |
| 348 | − .addSuccess(Schema.Array(Thing)), | |
| 349 | − ) | |
| 346 | + .add(HttpApiEndpoint.get("listThings")`/things`.addSuccess(Schema.Array(Thing))) | |
| 350 | 347 | .add( |
| 351 | 348 | HttpApiEndpoint.post("syncThing")`/sync/:id` |
| 352 | 349 | .setPath(Schema.Struct({ id: Schema.String })) |
| 353 | 350 | .addSuccess(Thing) |
| 354 | 351 | .addError(SyncError), |
| 355 | − ) | |
| 352 | + ); | |
| 356 | 353 | ``` |
| 357 | 354 | |
| 358 | 355 | ```ts |
| @@ -429,49 +426,47 @@ Lives in `@executor-js/sdk/client`. Server bundles cannot import this module. · 70 unmodified lines | ||
| 429 | 426 | ```ts |
| 430 | 427 | // packages/core/sdk/src/client.ts |
| 431 | 428 | export interface ClientPluginSpec<TId extends string = string> { |
| 432 | − readonly id: TId | |
| 429 | + readonly id: TId; | |
| 433 | 430 | |
| 434 | 431 | /** Pages contributed to the host's TanStack router. Mounted under |
| 435 | 432 | * /plugins/{id}/{path}. Sidebar nav metadata declared on the route. */ |
| 436 | − readonly pages?: readonly PageDecl[] | |
| 433 | + readonly pages?: readonly PageDecl[]; | |
| 437 | 434 | |
| 438 | 435 | /** Dashboard / overview widgets the host can render in known slots. */ |
| 439 | − readonly widgets?: readonly WidgetDecl[] | |
| 436 | + readonly widgets?: readonly WidgetDecl[]; | |
| 440 | 437 | |
| 441 | 438 | /** Components the host can render in named slots (e.g., source-detail |
| 442 | 439 | * panels, secret-picker variants). Slot names are part of the host |
| 443 | 440 | * contract — plugin opts in by registering. */ |
| 444 | − readonly slots?: Record<string, SlotComponent> | |
| 441 | + readonly slots?: Record<string, SlotComponent>; | |
| 445 | 442 | |
| 446 | 443 | /** Source-plugin contribution — populated by plugins that expose |
| 447 | 444 | * `kind` rows in the core `source` table (openapi, mcp, graphql, |
| 448 | 445 | * google-discovery). The host's sources page derives its provider |
| 449 | 446 | * list from the union of every loaded plugin's `sourcePlugin` |
| 450 | 447 | * via `useSourcePlugins()`. */ |
| 451 | − readonly sourcePlugin?: SourcePlugin | |
| 448 | + readonly sourcePlugin?: SourcePlugin; | |
| 452 | 449 | |
| 453 | 450 | /** Secret-provider-plugin contribution — populated by plugins that |
| 454 | 451 | * also ship a `secretProviders` server-side capability AND want to |
| 455 | 452 | * expose a settings card on the host's secrets page. Surfaced via |
| 456 | 453 | * `useSecretProviderPlugins()`. */ |
| 457 | − readonly secretProviderPlugin?: SecretProviderPlugin | |
| 454 | + readonly secretProviderPlugin?: SecretProviderPlugin; | |
| 458 | 455 | } |
| 459 | 456 | |
| 460 | 457 | type PageDecl = { |
| 461 | − path: string // "/", "/edit/$id" | |
| 462 | − component: ComponentType | |
| 463 | − nav?: { label: string; section?: string } | |
| 464 | −} | |
| 458 | + path: string; // "/", "/edit/$id" | |
| 459 | + component: ComponentType; | |
| 460 | + nav?: { label: string; section?: string }; | |
| 461 | +}; | |
| 465 | 462 | |
| 466 | 463 | type WidgetDecl = { |
| 467 | − id: string | |
| 468 | − component: ComponentType<WidgetProps> | |
| 469 | − size?: "half" | "full" | |
| 470 | −} | |
| 464 | + id: string; | |
| 465 | + component: ComponentType<WidgetProps>; | |
| 466 | + size?: "half" | "full"; | |
| 467 | +}; | |
| 471 | 468 | |
| 472 | −export const defineClientPlugin = <TId extends string>( | |
| 473 | − spec: ClientPluginSpec<TId>, | |
| 474 | −) => spec | |
| 469 | +export const defineClientPlugin = <TId extends string>(spec: ClientPluginSpec<TId>) => spec; | |
| 475 | 470 | ``` |
| 476 | 471 | |
| 477 | 472 | ### `createPluginAtomClient` — typed reactive client per plugin |
| @@ -483,24 +478,21 @@ authors write one line per atom. Same shape as the existing · 5 unmodified lines | ||
| 483 | 478 | |
| 484 | 479 | ```ts |
| 485 | 480 | // packages/core/sdk/src/client.ts (helper) |
| 486 | −import { HttpApi } from "effect/unstable/httpapi" | |
| 487 | −import { FetchHttpClient } from "effect/unstable/http" | |
| 488 | −import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi" | |
| 481 | +import { HttpApi } from "effect/unstable/httpapi"; | |
| 482 | +import { FetchHttpClient } from "effect/unstable/http"; | |
| 483 | +import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; | |
| 489 | 484 | |
| 490 | 485 | export const createPluginAtomClient = <G extends HttpApiGroup.Any>( |
| 491 | 486 | group: G, |
| 492 | 487 | opts: { pluginId: string }, |
| 493 | 488 | ) => { |
| 494 | − const bundle = HttpApi.make(`plugin-${opts.pluginId}`).add(group) | |
| 495 | − return AtomHttpApi.Service<`Plugin_${string}Client`>()( | |
| 496 | − `Plugin_${opts.pluginId}Client`, | |
| 497 | − { | |
| 498 | − api: bundle, | |
| 499 | − httpClient: FetchHttpClient.layer, | |
| 500 | − baseUrl: `/_executor/plugins/${opts.pluginId}`, | |
| 501 | − }, | |
| 502 | − ) | |
| 503 | −} | |
| 489 | + const bundle = HttpApi.make(`plugin-${opts.pluginId}`).add(group); | |
| 490 | + return AtomHttpApi.Service<`Plugin_${string}Client`>()(`Plugin_${opts.pluginId}Client`, { | |
| 491 | + api: bundle, | |
| 492 | + httpClient: FetchHttpClient.layer, | |
| 493 | + baseUrl: `/_executor/plugins/${opts.pluginId}`, | |
| 494 | + }); | |
| 495 | +}; | |
| 504 | 496 | ``` |
| 505 | 497 | |
| 506 | 498 | Plugin author writes: |
| @@ -513,39 +505,34 @@ import { · 6 unmodified lines | ||
| 513 | 505 | useAtomValue, |
| 514 | 506 | useAtomSet, |
| 515 | 507 | AsyncResult, |
| 516 | −} from "@executor-js/sdk/client" | |
| 517 | −import { FooApi } from "./shared" | |
| 508 | +} from "@executor-js/sdk/client"; | |
| 509 | +import { FooApi } from "./shared"; | |
| 518 | 510 | |
| 519 | −const FooClient = createPluginAtomClient(FooApi, { pluginId: "foo" }) | |
| 511 | +const FooClient = createPluginAtomClient(FooApi, { pluginId: "foo" }); | |
| 520 | 512 | |
| 521 | 513 | export const fooThingsAtom = FooClient.query("foo", "listThings", { |
| 522 | 514 | timeToLive: "30 seconds", |
| 523 | 515 | reactivityKeys: ["foo:things"], |
| 524 | −}) | |
| 516 | +}); | |
| 525 | 517 | |
| 526 | −export const fooSync = FooClient.mutation("foo", "syncThing") | |
| 518 | +export const fooSync = FooClient.mutation("foo", "syncThing"); | |
| 527 | 519 | |
| 528 | 520 | const FooPage = () => { |
| 529 | − const things = useAtomValue(fooThingsAtom) | |
| 530 | − const doSync = useAtomSet(fooSync, { mode: "promise" }) | |
| 521 | + const things = useAtomValue(fooThingsAtom); | |
| 522 | + const doSync = useAtomSet(fooSync, { mode: "promise" }); | |
| 531 | 523 | |
| 532 | 524 | return AsyncResult.match(things, { |
| 533 | 525 | onInitial: () => <Skeleton />, |
| 534 | 526 | onFailure: () => <p>Failed to load</p>, |
| 535 | − onSuccess: ({ value }) => ( | |
| 536 | − <Table | |
| 537 | − rows={value} | |
| 538 | − onSync={(id) => doSync({ path: { id } })} | |
| 539 | − /> | |
| 540 | − ), | |
| 541 | − }) | |
| 542 | −} | |
| 527 | + onSuccess: ({ value }) => <Table rows={value} onSync={(id) => doSync({ path: { id } })} />, | |
| 528 | + }); | |
| 529 | +}; | |
| 543 | 530 | |
| 544 | 531 | export default defineClientPlugin({ |
| 545 | 532 | id: "foo" as const, |
| 546 | 533 | pages: [{ path: "/", component: FooPage, nav: { label: "Foo" } }], |
| 547 | 534 | widgets: [{ id: "foo-status", component: FooStatus, size: "half" }], |
| 548 | −}) | |
| 535 | +}); | |
| 549 | 536 | ``` |
| 550 | 537 | |
| 551 | 538 | Type inference: `FooClient.query("foo", "listThings", ...)` is fully typed |
| @@ -608,15 +595,17 @@ Single config, two consumers. · 56 unmodified lines | ||
| 608 | 595 | |
| 609 | 596 | ```ts |
| 610 | 597 | // apps/local/src/server/executor.ts (after) |
| 611 | −import config from "../../executor.config.ts" | |
| 612 | − | |
| 613 | −const executor = yield* createExecutor({ | |
| 614 | − scopes: [scope], | |
| 615 | − adapter, | |
| 616 | − blobs, | |
| 617 | − plugins: config.plugins, | |
| 618 | − onElicitation: "accept-all", | |
| 619 | −}) | |
| 598 | +import config from "../../executor.config.ts"; | |
| 599 | + | |
| 600 | +const executor = | |
| 601 | + yield * | |
| 602 | + createExecutor({ | |
| 603 | + scopes: [scope], | |
| 604 | + adapter, | |
| 605 | + blobs, | |
| 606 | + plugins: config.plugins, | |
| 607 | + onElicitation: "accept-all", | |
| 608 | + }); | |
| 620 | 609 | ``` |
| 621 | 610 | |
| 622 | 611 | The plugins are already configured (factory called) by the time |
| @@ -631,18 +620,16 @@ export default function executorVite(): Plugin { · 8 unmodified lines | ||
| 631 | 620 | return { |
| 632 | 621 | name: "executor-plugins", |
| 633 | 622 | resolveId(id) { |
| 634 | − if (id === "virtual:executor/plugins-client") return "\0" + id | |
| 623 | + if (id === "virtual:executor/plugins-client") return "\0" + id; | |
| 635 | 624 | }, |
| 636 | 625 | async load(id) { |
| 637 | − if (id !== "\0virtual:executor/plugins-client") return | |
| 638 | − const config = await loadExecutorConfig() | |
| 639 | − const imports = config.plugins | |
| 640 | − .map((p, i) => `import p${i} from "${p.id}/client"`) | |
| 641 | − .join("\n") | |
| 642 | − const list = config.plugins.map((_, i) => `p${i}`).join(", ") | |
| 643 | − return `${imports}\nexport const plugins = [${list}]` | |
| 626 | + if (id !== "\0virtual:executor/plugins-client") return; | |
| 627 | + const config = await loadExecutorConfig(); | |
| 628 | + const imports = config.plugins.map((p, i) => `import p${i} from "${p.id}/client"`).join("\n"); | |
| 629 | + const list = config.plugins.map((_, i) => `p${i}`).join(", "); | |
| 630 | + return `${imports}\nexport const plugins = [${list}]`; | |
| 644 | 631 | }, |
| 645 | − } | |
| 632 | + }; | |
| 646 | 633 | } |
| 647 | 634 | ``` |
| 648 | 635 | |
| @@ -655,11 +642,11 @@ file — `__root.tsx` is the only place that touches · 6 unmodified lines | ||
| 655 | 642 | |
| 656 | 643 | ```tsx |
| 657 | 644 | // apps/local/src/routes/__root.tsx |
| 658 | −import { createRootRoute } from "@tanstack/react-router" | |
| 659 | −import { ExecutorProvider } from "@executor-js/react/api/provider" | |
| 660 | −import { ExecutorPluginsProvider } from "@executor-js/sdk/client" | |
| 661 | −import { plugins as clientPlugins } from "virtual:executor/plugins-client" | |
| 662 | −import { Shell } from "../web/shell" | |
| 645 | +import { createRootRoute } from "@tanstack/react-router"; | |
| 646 | +import { ExecutorProvider } from "@executor-js/react/api/provider"; | |
| 647 | +import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; | |
| 648 | +import { plugins as clientPlugins } from "virtual:executor/plugins-client"; | |
| 649 | +import { Shell } from "../web/shell"; | |
| 663 | 650 | |
| 664 | 651 | export const Route = createRootRoute({ |
| 665 | 652 | component: () => ( |
| @@ -669,15 +656,15 @@ export const Route = createRootRoute({ · 3 unmodified lines | ||
| 669 | 656 | </ExecutorPluginsProvider> |
| 670 | 657 | </ExecutorProvider> |
| 671 | 658 | ), |
| 672 | −}) | |
| 659 | +}); | |
| 673 | 660 | ``` |
| 674 | 661 | |
| 675 | 662 | ```tsx |
| 676 | 663 | // e.g. inside packages/react/src/pages/sources.tsx |
| 677 | −import { useSourcePlugins } from "@executor-js/sdk/client" | |
| 664 | +import { useSourcePlugins } from "@executor-js/sdk/client"; | |
| 678 | 665 | |
| 679 | 666 | export function SourcesPage() { |
| 680 | − const sourcePlugins = useSourcePlugins() | |
| 667 | + const sourcePlugins = useSourcePlugins(); | |
| 681 | 668 | // ... |
| 682 | 669 | } |
| 683 | 670 | ``` |
| @@ -701,49 +688,48 @@ needs a Vite restart (not a hot update — config changed). · 17 unmodified lines | ||
| 701 | 688 | |
| 702 | 689 | ```ts |
| 703 | 690 | // src/shared.ts — only @executor-js/sdk imports, no raw effect imports |
| 704 | −import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk" | |
| 691 | +import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk"; | |
| 705 | 692 | |
| 706 | 693 | export const Greeting = Schema.Struct({ |
| 707 | 694 | message: Schema.String, |
| 708 | 695 | count: Schema.Number, |
| 709 | −}) | |
| 710 | −export type Greeting = typeof Greeting.Type | |
| 696 | +}); | |
| 697 | +export type Greeting = typeof Greeting.Type; | |
| 711 | 698 | |
| 712 | 699 | export const ExampleApi = HttpApiGroup.make("example").add( |
| 713 | 700 | HttpApiEndpoint.post("greet", "/greet", { |
| 714 | 701 | payload: Schema.Struct({ name: Schema.String }), |
| 715 | 702 | success: Greeting, |
| 716 | 703 | }), |
| 717 | −) | |
| 704 | +); | |
| 718 | 705 | ``` |
| 719 | 706 | |
| 720 | 707 | ```ts |
| 721 | 708 | // src/server.ts |
| 722 | −import { | |
| 723 | − Context, definePlugin, Effect, HttpApi, HttpApiBuilder, | |
| 724 | −} from "@executor-js/sdk" | |
| 725 | −import { ExampleApi } from "./shared" | |
| 709 | +import { Context, definePlugin, Effect, HttpApi, HttpApiBuilder } from "@executor-js/sdk"; | |
| 710 | +import { ExampleApi } from "./shared"; | |
| 726 | 711 | |
| 727 | −const ExampleApiBundle = HttpApi.make("example").add(ExampleApi) | |
| 712 | +const ExampleApiBundle = HttpApi.make("example").add(ExampleApi); | |
| 728 | 713 | |
| 729 | 714 | interface ExampleExtension { |
| 730 | 715 | readonly greet: ( |
| 731 | 716 | name: string, |
| 732 | − ) => Effect.Effect<{ readonly message: string; readonly count: number }> | |
| 717 | + ) => Effect.Effect<{ readonly message: string; readonly count: number }>; | |
| 733 | 718 | } |
| 734 | 719 | |
| 735 | 720 | export class ExampleExtensionService extends Context.Service< |
| 736 | − ExampleExtensionService, ExampleExtension, | |
| 721 | + ExampleExtensionService, | |
| 722 | + ExampleExtension | |
| 737 | 723 | >()("ExampleExtensionService") {} |
| 738 | 724 | |
| 739 | 725 | const ExampleHandlers = HttpApiBuilder.group(ExampleApiBundle, "example", (h) => |
| 740 | 726 | h.handle("greet", ({ payload }) => |
| 741 | 727 | Effect.gen(function* () { |
| 742 | − const ext = yield* ExampleExtensionService | |
| 743 | − return yield* ext.greet(payload.name) | |
| 728 | + const ext = yield* ExampleExtensionService; | |
| 729 | + return yield* ext.greet(payload.name); | |
| 744 | 730 | }), |
| 745 | 731 | ), |
| 746 | −) | |
| 732 | +); | |
| 747 | 733 | |
| 748 | 734 | export const examplePlugin = definePlugin(() => ({ |
| 749 | 735 | id: "example" as const, |
| @@ -754,57 +740,57 @@ export const examplePlugin = definePlugin(() => ({ · 4 unmodified lines | ||
| 754 | 740 | extension: (ctx): ExampleExtension => ({ |
| 755 | 741 | greet: (name: string) => |
| 756 | 742 | Effect.sync(() => { |
| 757 | − ctx.storage.count += 1 | |
| 743 | + ctx.storage.count += 1; | |
| 758 | 744 | return { |
| 759 | 745 | message: `hello ${name}`, |
| 760 | 746 | count: ctx.storage.count, |
| 761 | − } | |
| 747 | + }; | |
| 762 | 748 | }), |
| 763 | 749 | }), |
| 764 | 750 | |
| 765 | 751 | routes: () => ExampleApi, |
| 766 | 752 | handlers: () => ExampleHandlers, |
| 767 | 753 | extensionService: ExampleExtensionService, |
| 768 | −})) | |
| 754 | +})); | |
| 769 | 755 | |
| 770 | −export default examplePlugin | |
| 756 | +export default examplePlugin; | |
| 771 | 757 | ``` |
| 772 | 758 | |
| 773 | 759 | ```tsx |
| 774 | 760 | // src/client.tsx |
| 775 | −import { | |
| 776 | − defineClientPlugin, | |
| 777 | − createPluginAtomClient, | |
| 778 | − useAtomSet, | |
| 779 | −} from "@executor-js/sdk/client" | |
| 780 | −import { useState } from "react" | |
| 781 | −import { ExampleApi } from "./shared" | |
| 761 | +import { defineClientPlugin, createPluginAtomClient, useAtomSet } from "@executor-js/sdk/client"; | |
| 762 | +import { useState } from "react"; | |
| 763 | +import { ExampleApi } from "./shared"; | |
| 782 | 764 | |
| 783 | −const ExampleClient = createPluginAtomClient(ExampleApi, { pluginId: "example" }) | |
| 765 | +const ExampleClient = createPluginAtomClient(ExampleApi, { pluginId: "example" }); | |
| 784 | 766 | |
| 785 | −const greetAtom = ExampleClient.mutation("example", "greet") | |
| 767 | +const greetAtom = ExampleClient.mutation("example", "greet"); | |
| 786 | 768 | |
| 787 | 769 | const ExamplePage = () => { |
| 788 | − const [name, setName] = useState("world") | |
| 789 | − const [result, setResult] = useState<string>() | |
| 790 | − const doGreet = useAtomSet(greetAtom, { mode: "promise" }) | |
| 770 | + const [name, setName] = useState("world"); | |
| 771 | + const [result, setResult] = useState<string>(); | |
| 772 | + const doGreet = useAtomSet(greetAtom, { mode: "promise" }); | |
| 791 | 773 | |
| 792 | 774 | return ( |
| 793 | 775 | <div> |
| 794 | 776 | <input value={name} onChange={(e) => setName(e.target.value)} /> |
| 795 | − <button onClick={async () => { | |
| 796 | − const g = await doGreet({ payload: { name } }) | |
| 797 | − setResult(`${g.message} (#${g.count})`) | |
| 798 | − }}>Greet</button> | |
| 777 | + <button | |
| 778 | + onClick={async () => { | |
| 779 | + const g = await doGreet({ payload: { name } }); | |
| 780 | + setResult(`${g.message} (#${g.count})`); | |
| 781 | + }} | |
| 782 | + > | |
| 783 | + Greet | |
| 784 | + </button> | |
| 799 | 785 | {result && <pre>{result}</pre>} |
| 800 | 786 | </div> |
| 801 | − ) | |
| 802 | −} | |
| 787 | + ); | |
| 788 | +}; | |
| 803 | 789 | |
| 804 | 790 | export default defineClientPlugin({ |
| 805 | 791 | id: "example" as const, |
| 806 | 792 | pages: [{ path: "/", component: ExamplePage, nav: { label: "Example" } }], |
| 807 | −}) | |
| 793 | +}); | |
| 808 | 794 | ``` |
| 809 | 795 | |
| 810 | 796 | ```jsonc |
| @@ -814,13 +800,13 @@ export default defineClientPlugin({ · 3 unmodified lines | ||
| 814 | 800 | "type": "module", |
| 815 | 801 | "exports": { |
| 816 | 802 | "./server": "./dist/server.js", |
| 817 | − "./client": "./dist/client.js" | |
| 803 | + "./client": "./dist/client.js", | |
| 818 | 804 | }, |
| 819 | 805 | "executor": { "id": "example", "version": "0.1.0" }, |
| 820 | 806 | "peerDependencies": { |
| 821 | 807 | "@executor-js/sdk": "workspace:*", |
| 822 | − "react": "catalog:" | |
| 823 | − } | |
| 808 | + "react": "catalog:", | |
| 809 | + }, | |
| 824 | 810 | } |
| 825 | 811 | ``` |
| 826 | 812 | |
| @@ -828,8 +814,8 @@ User adds it: · 1 unmodified line | ||
| 828 | 814 | |
| 829 | 815 | ```ts |
| 830 | 816 | // apps/local/executor.config.ts |
| 831 | −import { examplePlugin } from "@executor-js/plugin-example/server" | |
| 832 | −plugins: [/* ... */, examplePlugin()] | |
| 817 | +import { examplePlugin } from "@executor-js/plugin-example/server"; | |
| 818 | +plugins: [, /* ... */ examplePlugin()]; | |
| 833 | 819 | ``` |
| 834 | 820 | |
| 835 | 821 | ## SDK-only plugin example |
| @@ -852,81 +838,82 @@ file with no `./client` export, no `routes`, no `handlers`. · 16 unmodified lines | ||
| 852 | 838 | "type": "module", |
| 853 | 839 | "exports": { "./server": "./dist/server.js" }, |
| 854 | 840 | "executor": { "id": "rateLimiter", "version": "0.1.0" }, |
| 855 | − "peerDependencies": { "@executor-js/sdk": "workspace:*" } | |
| 841 | + "peerDependencies": { "@executor-js/sdk": "workspace:*" }, | |
| 856 | 842 | } |
| 857 | 843 | ``` |
| 858 | 844 | |
| 859 | 845 | ```ts |
| 860 | 846 | // src/server.ts |
| 861 | −import { definePlugin, defineSchema, Effect, Schema } from "@executor-js/sdk" | |
| 847 | +import { definePlugin, defineSchema, Effect, Schema } from "@executor-js/sdk"; | |
| 862 | 848 | |
| 863 | 849 | interface RateLimiterConfig { |
| 864 | − defaultLimit?: number | |
| 850 | + defaultLimit?: number; | |
| 865 | 851 | } |
| 866 | 852 | |
| 867 | −class RateLimitExceeded extends Schema.TaggedError<RateLimitExceeded>()( | |
| 868 | − "RateLimitExceeded", | |
| 869 | − { key: Schema.String, retryAfterMs: Schema.Number }, | |
| 870 | −) {} | |
| 871 | − | |
| 872 | −export const rateLimiterPlugin = definePlugin( | |
| 873 | − (opts: RateLimiterConfig = {}) => ({ | |
| 874 | − id: "rateLimiter" as const, | |
| 875 | − | |
| 876 | − schema: defineSchema({ | |
| 877 | − rate_buckets: { | |
| 878 | − fields: { | |
| 879 | − key: { type: "string", primary: true }, | |
| 880 | − tokens: { type: "number" }, | |
| 881 | − updated_at: { type: "number" }, | |
| 882 | − }, | |
| 853 | +class RateLimitExceeded extends Schema.TaggedError<RateLimitExceeded>()("RateLimitExceeded", { | |
| 854 | + key: Schema.String, | |
| 855 | + retryAfterMs: Schema.Number, | |
| 856 | +}) {} | |
| 857 | + | |
| 858 | +export const rateLimiterPlugin = definePlugin((opts: RateLimiterConfig = {}) => ({ | |
| 859 | + id: "rateLimiter" as const, | |
| 860 | + | |
| 861 | + schema: defineSchema({ | |
| 862 | + rate_buckets: { | |
| 863 | + fields: { | |
| 864 | + key: { type: "string", primary: true }, | |
| 865 | + tokens: { type: "number" }, | |
| 866 | + updated_at: { type: "number" }, | |
| 883 | 867 | }, |
| 884 | − }), | |
| 868 | + }, | |
| 869 | + }), | |
| 885 | 870 | |
| 886 | − storage: ({ adapter }) => ({ | |
| 887 | − adapter, | |
| 888 | − defaultLimit: opts.defaultLimit ?? 60, | |
| 889 | − }), | |
| 871 | + storage: ({ adapter }) => ({ | |
| 872 | + adapter, | |
| 873 | + defaultLimit: opts.defaultLimit ?? 60, | |
| 874 | + }), | |
| 890 | 875 | |
| 891 | − extension: (ctx) => ({ | |
| 892 | − check: (key: string, cost = 1) => | |
| 893 | − Effect.gen(function* () { | |
| 894 | − const bucket = yield* readOrCreateBucket(ctx.storage, key) | |
| 895 | − const refilled = refill(bucket, ctx.storage.defaultLimit) | |
| 896 | − if (refilled.tokens < cost) { | |
| 897 | − return yield* new RateLimitExceeded({ | |
| 898 | − key, | |
| 899 | − retryAfterMs: estimateRetry(refilled), | |
| 900 | − }) | |
| 901 | − } | |
| 902 | − yield* writeBucket(ctx.storage, key, refilled.tokens - cost) | |
| 903 | − return { allowed: true, remaining: refilled.tokens - cost } | |
| 904 | − }), | |
| 876 | + extension: (ctx) => ({ | |
| 877 | + check: (key: string, cost = 1) => | |
| 878 | + Effect.gen(function* () { | |
| 879 | + const bucket = yield* readOrCreateBucket(ctx.storage, key); | |
| 880 | + const refilled = refill(bucket, ctx.storage.defaultLimit); | |
| 881 | + if (refilled.tokens < cost) { | |
| 882 | + return yield* new RateLimitExceeded({ | |
| 883 | + key, | |
| 884 | + retryAfterMs: estimateRetry(refilled), | |
| 885 | + }); | |
| 886 | + } | |
| 887 | + yield* writeBucket(ctx.storage, key, refilled.tokens - cost); | |
| 888 | + return { allowed: true, remaining: refilled.tokens - cost }; | |
| 889 | + }), | |
| 905 | 890 | |
| 906 | − reset: (key: string) => | |
| 907 | − Effect.tryPromise(() => | |
| 908 | − ctx.storage.adapter.delete({ | |
| 909 | − model: "rate_buckets", | |
| 910 | − where: [["key", "=", key]], | |
| 911 | − }), | |
| 912 | − ), | |
| 913 | − }), | |
| 891 | + reset: (key: string) => | |
| 892 | + Effect.tryPromise(() => | |
| 893 | + ctx.storage.adapter.delete({ | |
| 894 | + model: "rate_buckets", | |
| 895 | + where: [["key", "=", key]], | |
| 896 | + }), | |
| 897 | + ), | |
| 914 | 898 | }), |
| 915 | −) | |
| 899 | +})); | |
| 916 | 900 | ``` |
| 917 | 901 | |
| 918 | 902 | Pure programmatic consumption — works identically from CLI, tests, |
| 919 | 903 | embedded library use, or another plugin's `extension`: |
| 920 | 904 | |
| 921 | 905 | ```ts |
| 922 | −const result = yield* executor.rateLimiter.check("user-123", 5) | |
| 906 | +const result = yield * executor.rateLimiter.check("user-123", 5); | |
| 923 | 907 | // ^? { allowed: true; remaining: number } |
| 924 | 908 | |
| 925 | −yield* executor.rateLimiter.check("user-123", 1000).pipe( | |
| 926 | − Effect.catchTag("RateLimitExceeded", (err) => | |
| 927 | − Effect.log(`hit limit on ${err.key}, retry in ${err.retryAfterMs}ms`), | |
| 928 | − ), | |
| 929 | −) | |
| 909 | +yield * | |
| 910 | + executor.rateLimiter | |
| 911 | + .check("user-123", 1000) | |
| 912 | + .pipe( | |
| 913 | + Effect.catchTag("RateLimitExceeded", (err) => | |
| 914 | + Effect.log(`hit limit on ${err.key}, retry in ${err.retryAfterMs}ms`), | |
| 915 | + ), | |
| 916 | + ); | |
| 930 | 917 | ``` |
| 931 | 918 | |
| 932 | 919 | What the host does with this plugin: |
| @@ -1000,6 +987,7 @@ Build order — all shipped. · 67 unmodified lines | ||
| 1000 | 987 | ## References |
| 1001 | 988 | |
| 1002 | 989 | **Reference research (pre-design):** |
| 990 | + | |
| 1003 | 991 | - emdash: `astro.config.mjs` import-and-call pattern, `package.json` exports |
| 1004 | 992 | for separate admin entry — see `.reference/emdash/demos/plugins-demo/`. |
| 1005 | 993 | - dynamic-software: PluginManifest + capability declarations idea (deferred); |
| @@ -1008,6 +996,7 @@ Build order — all shipped. · 2 unmodified lines | ||
| 1008 | 996 | when we add capability enforcement). |
| 1009 | 997 | |
| 1010 | 998 | **Shipped code:** |
| 999 | + | |
| 1011 | 1000 | - Plugin contract: `packages/core/sdk/src/plugin.ts` (`PluginSpec`, |
| 1012 | 1001 | `definePlugin`). |
| 1013 | 1002 | - Client contract: `packages/core/sdk/src/client.ts` |
| @@ -1030,6 +1019,7 @@ Build order — all shipped. · 16 unmodified lines | ||
| 1030 | 1019 | follows the same per-capability-typed-field pattern. |
| 1031 | 1020 | |
| 1032 | 1021 | **Adjacent plans:** |
| 1022 | + | |
| 1033 | 1023 | - `notes/artifacts-workflows-and-generated-ui.md` — the artifacts/workflows |
| 1034 | 1024 | plan. Uses "protocol" terminology that we're not adopting; read its |
| 1035 | 1025 | "ArtifactStoreProtocol" as "the typed interface that goes into the |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | The MCP plugin **already pools connections within a session DO**. Production |
| 6 | 6 | telemetry attributing 17% of `plugin.mcp.connection.acquire` calls to a |
| 7 | −fresh `plugin.mcp.connection.handshake` represents *cold-start sessions*, | |
| 7 | +fresh `plugin.mcp.connection.handshake` represents _cold-start sessions_, | |
| 8 | 8 | not redundant in-session handshakes. No structural change is required; |
| 9 | 9 | this PR ships a strict regression test that pins the existing contract |
| 10 | 10 | plus a `plugin.mcp.cache_hit` span attribute so future telemetry can |
| @@ -68,11 +68,12 @@ mutating the cache key per call) trips immediately in CI. · 57 unmodified lines | ||
| 68 | 68 | ## Re-reading the production trace |
| 69 | 69 | |
| 70 | 70 | Trace `b7102047bed975da461c0519d1251de4`: |
| 71 | + | |
| 71 | 72 | - `mcp.plugin.resolve_connector` 2.72s |
| 72 | −- `plugin.mcp.connection.acquire` 2.72s | |
| 73 | +- `plugin.mcp.connection.acquire` 2.72s | |
| 73 | 74 | - `plugin.mcp.connection.handshake` 2.52s |
| 74 | −- `executor.storage.transaction` 1.02s (token persist) | |
| 75 | −- `plugin.mcp.client.call_tool` 1.48s | |
| 75 | +- `executor.storage.transaction` 1.02s (token persist) | |
| 76 | +- `plugin.mcp.client.call_tool` 1.48s | |
| 76 | 77 | |
| 77 | 78 | Span nesting plus `resolve_connector ≈ acquire ≈ handshake` durations is |
| 78 | 79 | the cache-miss path: `resolveConnector` only runs (and emits its span) |
| @@ -80,11 +81,12 @@ when the cache lookup actually invokes the lookup closure, which only · 1 unmodified line | ||
| 80 | 81 | happens on a miss. |
| 81 | 82 | |
| 82 | 83 | 8h aggregate: |
| 84 | + | |
| 83 | 85 | - `plugin.mcp.connection.handshake` count 4 |
| 84 | −- `plugin.mcp.connection.acquire` count 24 | |
| 85 | −- `mcp.plugin.resolve_connector` count 4 | |
| 86 | +- `plugin.mcp.connection.acquire` count 24 | |
| 87 | +- `mcp.plugin.resolve_connector` count 4 | |
| 86 | 88 | |
| 87 | −Acquire count is 6× handshake count. The cache *is* hitting on 20 of 24 | |
| 89 | +Acquire count is 6× handshake count. The cache _is_ hitting on 20 of 24 | |
| 88 | 90 | calls. The 4 misses are cold-start sessions (each MCP session DO's first |
| 89 | 91 | tool call, plus any session where the connection went stale and was |
| 90 | 92 | invalidated by the retry path). Six tool calls per session is consistent |
| @@ -21,7 +21,7 @@ to preserve auth state across the upgrade. · 20 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | 1. `INSERT INTO connection` with the new pointer's `connectionId`, |
| 23 | 23 | `provider = "openapi:oauth2"`, and `provider_state` = `{flow, |
| 24 | − tokenUrl, clientIdSecretId, clientSecretSecretId, scopes}` lifted | |
| 24 | +tokenUrl, clientIdSecretId, clientSecretSecretId, scopes}` lifted | |
| 25 | 25 | from the legacy row. `access_token_secret_id` + `refresh_token_secret_id` |
| 26 | 26 | reuse the legacy secret ids — tokens themselves never move, only |
| 27 | 27 | ownership links flip. |
| @@ -87,7 +87,7 @@ Owns the translation, the opaque wire schema, and the capture service. · 86 unmodified lines | ||
| 87 | 87 | |
| 88 | 88 | ```ts |
| 89 | 89 | interface ErrorCaptureShape { |
| 90 | − readonly captureException: (cause: Cause<unknown>) => Effect<string> | |
| 90 | + readonly captureException: (cause: Cause<unknown>) => Effect<string>; | |
| 91 | 91 | } |
| 92 | 92 | ``` |
| 93 | 93 | |
| @@ -130,7 +130,7 @@ Plugin authors write normal Effect code. Their extension method error · 36 unmodified lines | ||
| 130 | 130 | unions look like: |
| 131 | 131 | |
| 132 | 132 | ```ts |
| 133 | −Effect.Effect<X, MyPluginTypedError | StorageError, never> | |
| 133 | +Effect.Effect<X, MyPluginTypedError | StorageError, never>; | |
| 134 | 134 | ``` |
| 135 | 135 | |
| 136 | 136 | Where `MyPluginTypedError` is the union of their own |
| @@ -152,9 +152,9 @@ class McpGroup extends HttpApiGroup.make("mcp") · 15 unmodified lines | ||
| 152 | 152 | // … |
| 153 | 153 | .addError(InternalError) |
| 154 | 154 | .addError(McpOAuthError) |
| 155 | − .addError(McpConnectionError) | |
| 155 | + .addError(McpConnectionError) { | |
| 156 | 156 | // … |
| 157 | −{} | |
| 157 | +} | |
| 158 | 158 | ``` |
| 159 | 159 | |
| 160 | 160 | No per-endpoint `addError`. The framework encodes each tagged error by |
| @@ -54,7 +54,7 @@ source: { · 53 unmodified lines | ||
| 54 | 54 | Effect.catchAll((e) => { |
| 55 | 55 | const message = e instanceof Error ? e.message : String(e); |
| 56 | 56 | return executor.sources.recordError({ source, message }).pipe(Effect.asVoid); |
| 57 | −}) | |
| 57 | +}); | |
| 58 | 58 | ``` |
| 59 | 59 | |
| 60 | 60 | `executor.sources.recordError` is a new core method that writes a |
| @@ -76,6 +76,7 @@ a special case — current `removeSource` already works regardless of · 15 unmodified lines | ||
| 76 | 76 | ## Scope |
| 77 | 77 | |
| 78 | 78 | Not for the scope-refactor PR. Separate change: |
| 79 | + | |
| 79 | 80 | - Schema migration adds two columns. |
| 80 | 81 | - `executor.sources.recordError` on the core SDK. |
| 81 | 82 | - Wire up in config-sync. |
| @@ -25,7 +25,7 @@ Tables affected (all drop candidates): · 24 unmodified lines | ||
| 25 | 25 | - `workos_vault_metadata_scope_id_idx` |
| 26 | 26 | |
| 27 | 27 | Root cause: `core-schema.ts` and plugin schemas mark `scope_id: { |
| 28 | −index: true }`. The drizzle generator should probably *skip* emitting | |
| 28 | +index: true }`. The drizzle generator should probably _skip_ emitting | |
| 29 | 29 | a single-column index on a column that's already the leftmost column |
| 30 | 30 | of a composite primary key. Fix in two places: |
| 31 | 31 | |
| @@ -5,10 +5,10 @@ deleting one silently drops coverage that the others can't replace. · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | ## Suites at a glance |
| 7 | 7 | |
| 8 | −| file | pool | drives | what it proves | | |
| 9 | −|---|---|---|---| | |
| 10 | −| `apps/cloud/src/mcp-session.e2e.node.test.ts` | node | `InMemoryTransport` + SDK `Client` | engine + plugin wiring; schema drift; elicitation semantics | | |
| 11 | −| `apps/cloud/src/mcp-flow.test.ts` | workerd (vitest-pool-workers) | `SELF.fetch` + hand-rolled JSON-RPC | edge HTTP pipeline that does not require a live multi-request DO session | | |
| 8 | +| file | pool | drives | what it proves | | |
| 9 | +| ----------------------------------------------- | ----------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- | | |
| 10 | +| `apps/cloud/src/mcp-session.e2e.node.test.ts` | node | `InMemoryTransport` + SDK `Client` | engine + plugin wiring; schema drift; elicitation semantics | | |
| 11 | +| `apps/cloud/src/mcp-flow.test.ts` | workerd (vitest-pool-workers) | `SELF.fetch` + hand-rolled JSON-RPC | edge HTTP pipeline that does not require a live multi-request DO session | | |
| 12 | 12 | | `apps/cloud/src/mcp-miniflare.e2e.node.test.ts` | node + miniflare-on-real-port | SDK `Client` + real `StreamableHTTPClientTransport` | the long-lived-socket DO runtime actually works end-to-end; elicitation round-trips real HTTP | |
| 13 | 13 | |
| 14 | 14 | ## The workerd cross-request I/O wall |
| @@ -86,12 +86,12 @@ Pattern used in `mcp-miniflare.e2e.node.test.ts`: · 71 unmodified lines | ||
| 86 | 86 | 3. Read the bound port from `HttpServer.HttpServer.address` (TcpAddress tag) |
| 87 | 87 | inside a `Layer.effect` that produces the test service. |
| 88 | 88 | 4. Generate the spec with `OpenApi.fromApi(api)` and inject |
| 89 | − `servers: [{ url: `http://127.0.0.1:${port}` }]` before `JSON.stringify`. | |
| 89 | + `servers: [{ url: `http://127.0.0.1:${port}` }]`before`JSON.stringify`. | |
| 90 | 90 | 5. SDK Client advertises `capabilities: { elicitation: { form: {} } }` and |
| 91 | 91 | registers `client.setRequestHandler(ElicitRequestSchema, ...)` that returns |
| 92 | 92 | `{ action: "accept", content: {} }`. |
| 93 | 93 | 6. Call `execute` with code that (a) calls `tools.openapi.addSource({ spec, |
| 94 | − namespace })`, (b) invokes the POST operation. | |
| 94 | +namespace })`, (b) invokes the POST operation. | |
| 95 | 95 | |
| 96 | 96 | **Tool id format inside the `execute` sandbox.** |
| 97 | 97 | `tools.<sourceId>.<group>.<operation>` for openapi. The Effect |
| @@ -35,7 +35,9 @@ export interface CoreStore { · 34 unmodified lines | ||
| 35 | 35 | |
| 36 | 36 | // definitions |
| 37 | 37 | readonly putDefinitions: (defs: readonly StoredDefinition[]) => Effect.Effect<void, Error>; |
| 38 | − readonly listDefinitionsBySource: (sourceId: string) => Effect.Effect<readonly StoredDefinition[], Error>; | |
| 38 | + readonly listDefinitionsBySource: ( | |
| 39 | + sourceId: string, | |
| 40 | + ) => Effect.Effect<readonly StoredDefinition[], Error>; | |
| 39 | 41 | readonly removeDefinitionsBySource: (sourceId: string) => Effect.Effect<void, Error>; |
| 40 | 42 | |
| 41 | 43 | // secrets (routing rows; provider-specific data lives in provider) |
| @@ -45,10 +47,9 @@ export interface CoreStore { · 3 unmodified lines | ||
| 45 | 47 | readonly removeSecret: (id: string) => Effect.Effect<void, Error>; |
| 46 | 48 | } |
| 47 | 49 | |
| 48 | −export const makeDefaultCoreStore = (deps: { | |
| 49 | − adapter: DBAdapter; | |
| 50 | − scope: Scope; | |
| 51 | −}): CoreStore => { /* typedAdapter<CoreSchema> wiring, one place */ }; | |
| 50 | +export const makeDefaultCoreStore = (deps: { adapter: DBAdapter; scope: Scope }): CoreStore => { | |
| 51 | + /* typedAdapter<CoreSchema> wiring, one place */ | |
| 52 | +}; | |
| 52 | 53 | ``` |
| 53 | 54 | |
| 54 | 55 | ## Wiring — defaults passed in deps, force the spread |
| @@ -70,9 +71,7 @@ export interface ExecutorConfig<TPlugins> { · 15 unmodified lines | ||
| 70 | 71 | |
| 71 | 72 | // Inside createExecutor: |
| 72 | 73 | const defaults = makeDefaultCoreStore({ adapter, scope }); |
| 73 | −const core = config.coreStorage | |
| 74 | − ? config.coreStorage({ adapter, scope, defaults }) | |
| 75 | − : defaults; | |
| 74 | +const core = config.coreStorage ? config.coreStorage({ adapter, scope, defaults }) : defaults; | |
| 76 | 75 | // Executor now calls core.putTools(...), core.removeSource(...), etc. |
| 77 | 76 | // No more adapter.create({ model: "tool", ... }) scattered through executor.ts. |
| 78 | 77 | ``` |
| @@ -83,7 +82,7 @@ const core = config.coreStorage · 4 unmodified lines | ||
| 83 | 82 | with no type help. |
| 84 | 83 | - Anyone implementing from scratch (test doubles, a non-drizzle backend) |
| 85 | 84 | gets no squigglies when they miss a method. |
| 86 | −- Defaults-in-deps gets "only write what you override" ergonomics *and* | |
| 85 | +- Defaults-in-deps gets "only write what you override" ergonomics _and_ | |
| 87 | 86 | keeps the full `CoreStore` return type enforced. |
| 88 | 87 | |
| 89 | 88 | ## What it unlocks |
| @@ -91,30 +90,39 @@ const core = config.coreStorage · 1 unmodified line | ||
| 91 | 90 | **Algolia sidecar — no string-match, just method overrides:** |
| 92 | 91 | |
| 93 | 92 | ```ts |
| 94 | −const executor = yield* createExecutor({ | |
| 95 | − scope, adapter, blobs, plugins, | |
| 96 | − | |
| 97 | − coreStorage: ({ defaults }) => ({ | |
| 98 | − ...defaults, | |
| 99 | − putTools: (tools) => | |
| 100 | − Effect.gen(function* () { | |
| 101 | − yield* defaults.putTools(tools); | |
| 102 | − yield* Effect.promise(() => algolia.saveObjects({ | |
| 103 | − indexName: `tools_${scope.id}`, | |
| 104 | − objects: tools.map((t) => ({ objectID: t.id, ...t })), | |
| 105 | − })); | |
| 106 | − }), | |
| 107 | − removeToolsBySource: (sourceId) => | |
| 108 | − Effect.gen(function* () { | |
| 109 | − const affected = yield* defaults.listTools(); | |
| 110 | − yield* defaults.removeToolsBySource(sourceId); | |
| 111 | − yield* Effect.promise(() => algolia.deleteObjects({ | |
| 112 | − indexName: `tools_${scope.id}`, | |
| 113 | − objectIDs: affected.filter((t) => t.sourceId === sourceId).map((t) => t.id), | |
| 114 | − })); | |
| 115 | − }), | |
| 116 | − }), | |
| 117 | −}); | |
| 93 | +const executor = | |
| 94 | + yield * | |
| 95 | + createExecutor({ | |
| 96 | + scope, | |
| 97 | + adapter, | |
| 98 | + blobs, | |
| 99 | + plugins, | |
| 100 | + | |
| 101 | + coreStorage: ({ defaults }) => ({ | |
| 102 | + ...defaults, | |
| 103 | + putTools: (tools) => | |
| 104 | + Effect.gen(function* () { | |
| 105 | + yield* defaults.putTools(tools); | |
| 106 | + yield* Effect.promise(() => | |
| 107 | + algolia.saveObjects({ | |
| 108 | + indexName: `tools_${scope.id}`, | |
| 109 | + objects: tools.map((t) => ({ objectID: t.id, ...t })), | |
| 110 | + }), | |
| 111 | + ); | |
| 112 | + }), | |
| 113 | + removeToolsBySource: (sourceId) => | |
| 114 | + Effect.gen(function* () { | |
| 115 | + const affected = yield* defaults.listTools(); | |
| 116 | + yield* defaults.removeToolsBySource(sourceId); | |
| 117 | + yield* Effect.promise(() => | |
| 118 | + algolia.deleteObjects({ | |
| 119 | + indexName: `tools_${scope.id}`, | |
| 120 | + objectIDs: affected.filter((t) => t.sourceId === sourceId).map((t) => t.id), | |
| 121 | + }), | |
| 122 | + ); | |
| 123 | + }), | |
| 124 | + }), | |
| 125 | + }); | |
| 118 | 126 | ``` |
| 119 | 127 | |
| 120 | 128 | Test doubles become trivial — either hand-roll the whole `CoreStore` |
| @@ -128,13 +136,13 @@ The factory name leaks. Rename to the defaults-in-deps shape: · 7 unmodified lines | ||
| 128 | 136 | |
| 129 | 137 | ```ts |
| 130 | 138 | // before |
| 131 | −storage: (deps) => makeDefaultOpenapiStore(deps) | |
| 139 | +storage: (deps) => makeDefaultOpenapiStore(deps); | |
| 132 | 140 | |
| 133 | 141 | // after |
| 134 | −storage: ({ defaults }) => defaults // no-op, identical to leaving off `storage:` | |
| 142 | +storage: ({ defaults }) => defaults; // no-op, identical to leaving off `storage:` | |
| 135 | 143 | |
| 136 | 144 | // override one method |
| 137 | −storage: ({ defaults }) => ({ ...defaults, upsertSource: myUpsert }) | |
| 145 | +storage: ({ defaults }) => ({ ...defaults, upsertSource: myUpsert }); | |
| 138 | 146 | ``` |
| 139 | 147 | |
| 140 | 148 | Mechanical sweep across openapi / mcp / graphql / workos-vault plugins. |
| @@ -152,6 +160,6 @@ Not intellectually hard, but a broad diff: · 11 unmodified lines | ||
| 152 | 160 | factory renamed to follow the `defaults` contract. |
| 153 | 161 | - Docs/readme updates showing the new override pattern. |
| 154 | 162 | |
| 155 | −Ideally lands *before* pluggable search — the search sidecar story | |
| 163 | +Ideally lands _before_ pluggable search — the search sidecar story | |
| 156 | 164 | depends on this refactor to avoid the `if (model === "tool")` antipattern |
| 157 | 165 | I'd otherwise be stuck with. |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | `Effect.runPromise(...)`. That works but it's lossy at the Promise |
| 7 | 7 | boundary: |
| 8 | 8 | |
| 9 | −- The promise's *rejection type* is `unknown` (or `any` after | |
| 9 | +- The promise's _rejection type_ is `unknown` (or `any` after | |
| 10 | 10 | `try/catch`). Consumers have to `instanceof InternalError` / |
| 11 | 11 | `err._tag === "..."` to discriminate, even though the underlying |
| 12 | 12 | Effect had a fully typed error union. |
| @@ -43,12 +43,14 @@ Do we expose Effect's `Exit` / `Cause` directly to promise consumers, · 30 unmodified lines | ||
| 43 | 43 | or wrap them in a promise-native `Result<A, E>`? |
| 44 | 44 | |
| 45 | 45 | **Expose `Exit`/`Cause`:** |
| 46 | + | |
| 46 | 47 | - Pros: one fewer abstraction, no translation layer to maintain, cause |
| 47 | 48 | preserves parallel/sequential composition. |
| 48 | 49 | - Cons: consumers depend on `effect`'s API even though they're using a |
| 49 | 50 | "Promise" SDK — partly defeats the abstraction. |
| 50 | 51 | |
| 51 | 52 | **Wrap in a `Result<A, E>`:** |
| 53 | + | |
| 52 | 54 | - Pros: `@executor-js/sdk/promise` consumers don't import `effect` at all, |
| 53 | 55 | surface stays small. |
| 54 | 56 | - Cons: another type to learn / document; loses some Cause structure |
| @@ -54,8 +54,8 @@ And app-side, once per request inside a transaction (so `SET LOCAL` · 53 unmodified lines | ||
| 54 | 54 | takes): |
| 55 | 55 | |
| 56 | 56 | ```ts |
| 57 | −yield* sql`SET LOCAL app.scope_chain = ${toPgArray(chain)}`; | |
| 58 | −yield* sql`SET LOCAL app.write_scope = ${writeScope}`; | |
| 57 | +yield * sql`SET LOCAL app.scope_chain = ${toPgArray(chain)}`; | |
| 58 | +yield * sql`SET LOCAL app.write_scope = ${writeScope}`; | |
| 59 | 59 | ``` |
| 60 | 60 | |
| 61 | 61 | `current_setting('…', true)` returns null when the setting is missing, |
| @@ -69,7 +69,7 @@ scope sees zero rows, not an error that might be handled into a 500. · 7 unmodified lines | ||
| 69 | 69 | tables is whatever `executor-schema.ts` exports with a `scope_id` |
| 70 | 70 | column — same set the scope adapter already knows about. |
| 71 | 71 | 2. Add an Effect layer that wraps the DbService to issue the two `SET |
| 72 | − LOCAL`s at connection acquire. `postgres.js`'s `sql.begin` already | |
| 72 | +LOCAL`s at connection acquire. `postgres.js`'s `sql.begin` already | |
| 73 | 73 | gives us the transaction boundary. |
| 74 | 74 | 3. Leave `BYPASSRLS` off the worker's role so we actually get the |
| 75 | 75 | enforcement; grant it on the role used by the migration / admin |
| @@ -20,7 +20,7 @@ The common primitive is not "org auth" or "user auth". It is an ordered · 19 unmodified lines | ||
| 20 | 20 | scope stack: |
| 21 | 21 | |
| 22 | 22 | ```ts |
| 23 | −[user, workspace, org] | |
| 23 | +[user, workspace, org]; | |
| 24 | 24 | ``` |
| 25 | 25 | |
| 26 | 26 | Rows still belong to exactly one scope. Resolution walks the stack |
| @@ -68,12 +68,7 @@ For example: · 41 unmodified lines | ||
| 68 | 68 | |
| 69 | 69 | ```ts |
| 70 | 70 | oauth2: { |
| 71 | − flow: "clientCredentials", | |
| 72 | − tokenUrl, | |
| 73 | − clientIdSlot, | |
| 74 | − clientSecretSlot, | |
| 75 | − connectionSlot, | |
| 76 | − scopes | |
| 71 | + flow: ("clientCredentials", tokenUrl, clientIdSlot, clientSecretSlot, connectionSlot, scopes); | |
| 77 | 72 | } |
| 78 | 73 | ``` |
| 79 | 74 | |
| @@ -10,15 +10,15 @@ scopes by construction. · 9 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | - **SDK** — `createExecutor({ scope: Scope, adapter, blobs, plugins })`. |
| 12 | 12 | One `Scope` per executor instance. `scopeAdapter(rootAdapter, {...}, |
| 13 | − schema)` wraps the adapter before it reaches plugin storage or the | |
| 13 | +schema)` wraps the adapter before it reaches plugin storage or the | |
| 14 | 14 | core-table writers. Every read on a scoped table gets `where scope_id |
| 15 | − = scope.id` ANDed in; every write gets `scope_id = scope.id` stamped | |
| 15 | += scope.id` ANDed in; every write gets `scope_id = scope.id` stamped | |
| 16 | 16 | into the payload. Tx handles passed into transaction callbacks are |
| 17 | 17 | also wrapped, so nested writes stay inside the same scope. |
| 18 | 18 | |
| 19 | 19 | - **Plugins** — see a plain `DBAdapter`. They do not know or care about |
| 20 | 20 | scope. Every plugin schema that wants isolation declares `scope_id: |
| 21 | − { type: "string", required: true, index: true }`. Forgetting the | |
| 21 | +{ type: "string", required: true, index: true }`. Forgetting the | |
| 22 | 22 | column means the adapter passes the plugin's reads/writes through |
| 23 | 23 | unscoped (documented failure mode, not silent) — tests catch the |
| 24 | 24 | concrete cases we care about. |
| @@ -44,8 +44,8 @@ sources, tools, secrets, and plugin-owned source detail lookups. · 19 unmodified lines | ||
| 44 | 44 | |
| 45 | 45 | ```ts |
| 46 | 46 | interface ScopeContext { |
| 47 | − readonly read: readonly string[]; // precedence-ordered, innermost first | |
| 48 | − readonly write: string; // exactly one scope for writes | |
| 47 | + readonly read: readonly string[]; // precedence-ordered, innermost first | |
| 48 | + readonly write: string; // exactly one scope for writes | |
| 49 | 49 | } |
| 50 | 50 | ``` |
| 51 | 51 | |
| @@ -91,7 +91,7 @@ Writes land in exactly one scope chosen by the caller. · 39 unmodified lines | ||
| 91 | 91 | there. When a user invokes Gmail, secret resolution walks the scope |
| 92 | 92 | stack (user → workspace → org) and only finds a token if that user |
| 93 | 93 | personally oauthed. Policy on the source row — `auth_scope_mandate: |
| 94 | − "user"` — forces this: secret lookup refuses to return | |
| 94 | +"user"` — forces this: secret lookup refuses to return | |
| 95 | 95 | workspace-level tokens even if one existed. |
| 96 | 96 | |
| 97 | 97 | - **Local global + per-folder.** `~/.executor/global` holds the outer |
| @@ -9,7 +9,7 @@ whatever) without baking any of it into the core. · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | 1. `executor.tools.list()` — `SELECT * FROM tool WHERE scope_id = ?`. |
| 11 | 11 | 2. In JS, for every tool: normalize + tokenize `id / sourceId / name / |
| 12 | − description`, score per field with weights, apply coverage + exact- | |
| 12 | +description`, score per field with weights, apply coverage + exact- | |
| 13 | 13 | phrase boosts, filter, sort, slice. |
| 14 | 14 | |
| 15 | 15 | O(N) per query over the scope. Fine at hundreds of tools, painful past |
| @@ -74,11 +74,13 @@ type. Same pattern as plugin `storage:` options and the future `coreStorage:`. · 58 unmodified lines | ||
| 74 | 74 | ## Override shapes |
| 75 | 75 | |
| 76 | 76 | **Replace entirely:** |
| 77 | + | |
| 77 | 78 | ```ts |
| 78 | 79 | search: ({ listTools }) => algoliaSearch({ listTools }), |
| 79 | 80 | ``` |
| 80 | 81 | |
| 81 | 82 | **Decorate — mix in logging/timing:** |
| 83 | + | |
| 82 | 84 | ```ts |
| 83 | 85 | search: ({ defaults }) => ({ |
| 84 | 86 | query: (q) => Effect.gen(function* () { |
| @@ -91,6 +93,7 @@ search: ({ defaults }) => ({ · 6 unmodified lines | ||
| 91 | 93 | ``` |
| 92 | 94 | |
| 93 | 95 | **Hybrid — try Algolia, fall back to local:** |
| 96 | + | |
| 94 | 97 | ```ts |
| 95 | 98 | search: ({ listTools, defaults }) => ({ |
| 96 | 99 | query: (q) => |
| @@ -109,6 +112,7 @@ it's a storage concern. Handled by wrapping the core store (see · 12 unmodified lines | ||
| 109 | 112 | delegate to the default and mirror to the external index. |
| 110 | 113 | |
| 111 | 114 | The two halves are independent: |
| 115 | + | |
| 112 | 116 | - Indexing only (manual reindex): override `coreStorage`, leave `search` |
| 113 | 117 | as default. |
| 114 | 118 | - Search only (some other populates the index): override `search`, leave |
| @@ -122,7 +126,7 @@ scorer: · 7 unmodified lines | ||
| 122 | 126 | 1. **Projection on `tools.list`** — expose a `select` option so search |
| 123 | 127 | doesn't pull `input_schema` / `output_schema`. Biggest per-query win. |
| 124 | 128 | 2. **Postgres FTS** — `tsvector` generated column on `(name, description, |
| 125 | − id, source_id)`, GIN index, use `websearch_to_tsquery`. Native | |
| 129 | +id, source_id)`, GIN index, use `websearch_to_tsquery`. Native | |
| 126 | 130 | ranking, fuzzy via `pg_trgm`. Would replace the in-memory scorer for |
| 127 | 131 | the cloud app. |
| 128 | 132 | 3. **Per-scope LRU** — cache scored results; invalidate on source |
| @@ -3,11 +3,13 @@ · 2 unmodified lines | ||
| 3 | 3 | ## What was done |
| 4 | 4 | |
| 5 | 5 | ### 1. DBSchema alignment with better-auth |
| 6 | + | |
| 6 | 7 | - Vendored better-auth's `BetterAuthPluginDBSchema` pattern into `storage-core/src/schema.ts` |
| 7 | 8 | - Made `modelName` optional (falls back to the key), renamed `disableMigrations` → `disableMigration`, dropped `order` |
| 8 | 9 | - Removed explicit `modelName` from all plugin schemas (core-schema, openapi, mcp, graphql, google-discovery, workos-vault) — keys already match table names |
| 9 | 10 | |
| 10 | 11 | ### 2. @executor-js/cli package (packages/core/cli/) |
| 12 | + | |
| 11 | 13 | - Mirrors better-auth's CLI approach: load config → collect plugin schemas → generate drizzle TS |
| 12 | 14 | - `executor generate --config ./executor.config.ts --output ./src/services/executor-schema.ts` |
| 13 | 15 | - Generator ported from better-auth's drizzle generator, adapted for our DBSchema |
| @@ -15,11 +17,13 @@ · 1 unmodified line | ||
| 15 | 17 | - Uses jiti for config loading (supports TS configs) |
| 16 | 18 | |
| 17 | 19 | ### 3. apps/cloud wired up |
| 20 | + | |
| 18 | 21 | - `executor.config.ts` defines plugins with stub credentials (only used for schema shape) |
| 19 | 22 | - `drizzle.config.ts` points at both `schema.ts` (cloud tables) and `executor-schema.ts` (executor tables) |
| 20 | 23 | - Fresh `drizzle/0000_initial.sql` with all tables (cloud + core + plugin + blob), indexes, FKs |
| 21 | 24 | |
| 22 | 25 | ### 4. apps/local wired up |
| 26 | + | |
| 23 | 27 | - `executor.config.ts` defines plugins + dialect sqlite |
| 24 | 28 | - `drizzle.config.ts` for drizzle-kit generate/push |
| 25 | 29 | - Generated `executor-schema.ts` with all sqlite tables + blob table |
| @@ -27,6 +31,7 @@ · 1 unmodified line | ||
| 27 | 31 | - At startup: `migrate(db, { migrationsFolder })` from `drizzle-orm/bun-sqlite/migrator` applies pending migrations before constructing the adapter |
| 28 | 32 | |
| 29 | 33 | ### 5. Adapter DDL removed |
| 34 | + | |
| 30 | 35 | - `makeSqliteAdapter` and `makePostgresAdapter` no longer run DDL — they return `DBAdapter` directly (not `Effect.Effect<DBAdapter>`) |
| 31 | 36 | - `makeSqliteBlobStore` and `makePostgresBlobStore` no longer run DDL — they return `BlobStore` directly |
| 32 | 37 | - `buildCreateTableStatements` deleted from `storage-file/compile.ts` |
| @@ -39,6 +44,7 @@ · 6 unmodified lines | ||
| 39 | 44 | The `@executor-js/config` package already maintains `executor.jsonc` as a source of truth for source configurations. The `config-store.ts` decorator intercepts `putSource`/`removeSource` on each plugin and mirrors to this file. So for existing users, `executor.jsonc` already has everything. |
| 40 | 45 | |
| 41 | 46 | ### Local app migration |
| 47 | + | |
| 42 | 48 | 1. On first run, `migrate()` creates all tables from the initial migration |
| 43 | 49 | 2. Add a **sync-from-config step** after executor creation: |
| 44 | 50 | - `loadConfig(configPath)` reads `executor.jsonc` |
| @@ -47,28 +53,30 @@ The `@executor-js/config` package already maintains `executor.jsonc` as a source · 2 unmodified lines | ||
| 47 | 53 | 3. Secrets survive untouched — they live in keychain/1password/file-secrets, not in the DB. The `secret` routing table gets re-populated when sources are added. |
| 48 | 54 | |
| 49 | 55 | ### What won't survive (acceptable losses) |
| 56 | + | |
| 50 | 57 | - Tool invocation history / blobs (old KV format) |
| 51 | 58 | - OAuth sessions (ephemeral, users re-auth) |
| 52 | 59 | - Stale source state (re-fetching is actually a benefit) |
| 53 | 60 | |
| 54 | 61 | ### Cloud migration |
| 62 | + | |
| 55 | 63 | - Only ~2 users, just drop old tables and re-add manually |
| 56 | 64 | - Or write a one-off SQL migration |
| 57 | 65 | |
| 58 | 66 | ## Key files |
| 59 | 67 | |
| 60 | −| File | Role | | |
| 61 | −|------|------| | |
| 62 | −| `packages/core/cli/src/commands/generate.ts` | CLI generate command | | |
| 63 | −| `packages/core/cli/src/generators/drizzle.ts` | Drizzle schema generator | | |
| 64 | −| `packages/core/sdk/src/config.ts` | `defineExecutorConfig` helper | | |
| 65 | −| `packages/core/config/src/schema.ts` | `executor.jsonc` schema | | |
| 66 | −| `packages/core/config/src/config-store.ts` | Plugin store → config file decorator | | |
| 67 | −| `packages/core/config/src/load.ts` | Config loading | | |
| 68 | −| `apps/cloud/executor.config.ts` | Cloud config for CLI | | |
| 69 | −| `apps/cloud/src/services/executor-schema.ts` | Generated drizzle schema | | |
| 70 | −| `apps/cloud/drizzle/0000_*.sql` | Fresh migration | | |
| 71 | −| `apps/local/executor.config.ts` | Local config for CLI | | |
| 72 | −| `apps/local/src/server/executor-schema.ts` | Generated drizzle schema | | |
| 73 | −| `apps/local/drizzle/0000_*.sql` | Fresh migration | | |
| 74 | −| `apps/local/src/server/executor.ts` | Local executor bootstrap with `migrate()` | | |
| 68 | +| File | Role | | |
| 69 | +| --------------------------------------------- | ----------------------------------------- | | |
| 70 | +| `packages/core/cli/src/commands/generate.ts` | CLI generate command | | |
| 71 | +| `packages/core/cli/src/generators/drizzle.ts` | Drizzle schema generator | | |
| 72 | +| `packages/core/sdk/src/config.ts` | `defineExecutorConfig` helper | | |
| 73 | +| `packages/core/config/src/schema.ts` | `executor.jsonc` schema | | |
| 74 | +| `packages/core/config/src/config-store.ts` | Plugin store → config file decorator | | |
| 75 | +| `packages/core/config/src/load.ts` | Config loading | | |
| 76 | +| `apps/cloud/executor.config.ts` | Cloud config for CLI | | |
| 77 | +| `apps/cloud/src/services/executor-schema.ts` | Generated drizzle schema | | |
| 78 | +| `apps/cloud/drizzle/0000_*.sql` | Fresh migration | | |
| 79 | +| `apps/local/executor.config.ts` | Local config for CLI | | |
| 80 | +| `apps/local/src/server/executor-schema.ts` | Generated drizzle schema | | |
| 81 | +| `apps/local/drizzle/0000_*.sql` | Fresh migration | | |
| 82 | +| `apps/local/src/server/executor.ts` | Local executor bootstrap with `migrate()` | | |
| @@ -72,24 +72,24 @@ The manifest should likely be a JSON-serializable object with these top-level se · 71 unmodified lines | ||
| 72 | 72 | |
| 73 | 73 | ```ts |
| 74 | 74 | type PluginManifest = { |
| 75 | − schemaVersion: string | |
| 76 | − id: string | |
| 77 | − name: string | |
| 78 | − version: string | |
| 79 | − description?: string | |
| 80 | − publisher?: PluginPublisher | |
| 81 | − license?: string | |
| 82 | − homepage?: string | |
| 83 | − repository?: string | |
| 84 | − categories?: PluginCategory[] | |
| 85 | − compatibility: PluginCompatibility | |
| 86 | − entrypoints: PluginEntrypoints | |
| 87 | − capabilities?: PluginCapabilities | |
| 88 | − extensions?: PluginExtensions | |
| 89 | − storage?: PluginStorageDeclaration | |
| 90 | − package?: PluginPackageDeclaration | |
| 91 | − metadata?: Record<string, unknown> | |
| 92 | −} | |
| 75 | + schemaVersion: string; | |
| 76 | + id: string; | |
| 77 | + name: string; | |
| 78 | + version: string; | |
| 79 | + description?: string; | |
| 80 | + publisher?: PluginPublisher; | |
| 81 | + license?: string; | |
| 82 | + homepage?: string; | |
| 83 | + repository?: string; | |
| 84 | + categories?: PluginCategory[]; | |
| 85 | + compatibility: PluginCompatibility; | |
| 86 | + entrypoints: PluginEntrypoints; | |
| 87 | + capabilities?: PluginCapabilities; | |
| 88 | + extensions?: PluginExtensions; | |
| 89 | + storage?: PluginStorageDeclaration; | |
| 90 | + package?: PluginPackageDeclaration; | |
| 91 | + metadata?: Record<string, unknown>; | |
| 92 | +}; | |
| 93 | 93 | ``` |
| 94 | 94 | |
| 95 | 95 | This is a planning type, not a final API. |
| @@ -135,9 +135,7 @@ The install surface should support package sources rather than assuming one cust · 39 unmodified lines | ||
| 135 | 135 | User-facing install config can stay simple: |
| 136 | 136 | |
| 137 | 137 | ```ts |
| 138 | −type PluginInstallSpec = | |
| 139 | − | string | |
| 140 | − | [string, Record<string, unknown>] | |
| 138 | +type PluginInstallSpec = string | [string, Record<string, unknown>]; | |
| 141 | 139 | ``` |
| 142 | 140 | |
| 143 | 141 | The string identifies the package source. Examples might include `@executor/openapi`, `github:owner/repo`, `https://github.com/owner/repo.git`, or `./plugins/my-plugin`. Tuple options are installation configuration, not manifest data. |
| @@ -146,10 +144,10 @@ Resolved install metadata should be stored separately from the manifest. It shou · 2 unmodified lines | ||
| 146 | 144 | |
| 147 | 145 | ```ts |
| 148 | 146 | type PluginPackageDeclaration = { |
| 149 | − source?: "npm" | "git" | "github" | "local" | "registry" | |
| 150 | − packageName?: string | |
| 151 | − repository?: string | |
| 152 | −} | |
| 147 | + source?: "npm" | "git" | "github" | "local" | "registry"; | |
| 148 | + packageName?: string; | |
| 149 | + repository?: string; | |
| 150 | +}; | |
| 153 | 151 | ``` |
| 154 | 152 | |
| 155 | 153 | This `package` block is optional planning shape, not final schema. For npm packages, standard `package.json` fields may already provide most of this information. |
| @@ -160,12 +158,12 @@ Compatibility should be explicit and checked before plugin code is loaded. · 4 unmodified lines | ||
| 160 | 158 | |
| 161 | 159 | ```ts |
| 162 | 160 | type PluginCompatibility = { |
| 163 | − executor: string | |
| 164 | − runtimeApi?: string | |
| 165 | − platforms?: PluginPlatform[] | |
| 166 | −} | |
| 161 | + executor: string; | |
| 162 | + runtimeApi?: string; | |
| 163 | + platforms?: PluginPlatform[]; | |
| 164 | +}; | |
| 167 | 165 | |
| 168 | −type PluginPlatform = "local" | "cloud" | |
| 166 | +type PluginPlatform = "local" | "cloud"; | |
| 169 | 167 | ``` |
| 170 | 168 | |
| 171 | 169 | `executor` should probably be a semver range for supported Executor versions. |
| @@ -182,27 +180,27 @@ Entrypoints describe executable code or assets the host may load. · 10 unmodified lines | ||
| 182 | 180 | |
| 183 | 181 | ```ts |
| 184 | 182 | type PluginEntrypoints = { |
| 185 | − main?: PluginCodeEntrypoint | |
| 186 | − sdk?: PluginCodeEntrypoint | |
| 187 | − source?: PluginCodeEntrypoint | |
| 188 | − secretStore?: PluginCodeEntrypoint | |
| 189 | − apiGroup?: PluginCodeEntrypoint | |
| 190 | − apiHandlers?: PluginCodeEntrypoint | |
| 191 | − routeHandlers?: Record<string, PluginCodeEntrypoint> | |
| 192 | − backgroundServices?: Record<string, PluginCodeEntrypoint> | |
| 193 | − reactSource?: PluginUiEntrypoint | |
| 194 | − reactSecretProvider?: PluginUiEntrypoint | |
| 195 | − ui?: PluginUiEntrypoint | |
| 196 | − adminUi?: PluginUiEntrypoint | |
| 197 | −} | |
| 183 | + main?: PluginCodeEntrypoint; | |
| 184 | + sdk?: PluginCodeEntrypoint; | |
| 185 | + source?: PluginCodeEntrypoint; | |
| 186 | + secretStore?: PluginCodeEntrypoint; | |
| 187 | + apiGroup?: PluginCodeEntrypoint; | |
| 188 | + apiHandlers?: PluginCodeEntrypoint; | |
| 189 | + routeHandlers?: Record<string, PluginCodeEntrypoint>; | |
| 190 | + backgroundServices?: Record<string, PluginCodeEntrypoint>; | |
| 191 | + reactSource?: PluginUiEntrypoint; | |
| 192 | + reactSecretProvider?: PluginUiEntrypoint; | |
| 193 | + ui?: PluginUiEntrypoint; | |
| 194 | + adminUi?: PluginUiEntrypoint; | |
| 195 | +}; | |
| 198 | 196 | |
| 199 | 197 | type PluginCodeEntrypoint = { |
| 200 | − module: string | |
| 201 | − export?: string | |
| 202 | − runtime?: PluginRuntimeTarget | |
| 203 | −} | |
| 198 | + module: string; | |
| 199 | + export?: string; | |
| 200 | + runtime?: PluginRuntimeTarget; | |
| 201 | +}; | |
| 204 | 202 | |
| 205 | −type PluginRuntimeTarget = "worker" | "isolate" | "host-trusted" | |
| 203 | +type PluginRuntimeTarget = "worker" | "isolate" | "host-trusted"; | |
| 206 | 204 | ``` |
| 207 | 205 | |
| 208 | 206 | The exact shape may change, but the manifest should avoid requiring the host to import a plugin just to discover what code exists. |
| @@ -237,21 +235,21 @@ Example planning shape: · 28 unmodified lines | ||
| 237 | 235 | ```ts |
| 238 | 236 | type PluginLayerDeclaration = { |
| 239 | 237 | sdk?: { |
| 240 | − pluginId: string | |
| 241 | − entrypoint: keyof PluginEntrypoints | |
| 242 | − } | |
| 238 | + pluginId: string; | |
| 239 | + entrypoint: keyof PluginEntrypoints; | |
| 240 | + }; | |
| 243 | 241 | api?: { |
| 244 | − requiresSdkPlugin: string | |
| 245 | − groupEntrypoint?: keyof PluginEntrypoints | |
| 246 | − handlersEntrypoint?: keyof PluginEntrypoints | |
| 247 | − } | |
| 242 | + requiresSdkPlugin: string; | |
| 243 | + groupEntrypoint?: keyof PluginEntrypoints; | |
| 244 | + handlersEntrypoint?: keyof PluginEntrypoints; | |
| 245 | + }; | |
| 248 | 246 | react?: Array<{ |
| 249 | − kind: "source" | "secret-provider" | "settings" | "admin" | |
| 250 | − requiresSdkPlugin?: string | |
| 251 | − requiresApi?: boolean | |
| 252 | − entrypoint: keyof PluginEntrypoints | |
| 253 | − }> | |
| 254 | −} | |
| 247 | + kind: "source" | "secret-provider" | "settings" | "admin"; | |
| 248 | + requiresSdkPlugin?: string; | |
| 249 | + requiresApi?: boolean; | |
| 250 | + entrypoint: keyof PluginEntrypoints; | |
| 251 | + }>; | |
| 252 | +}; | |
| 255 | 253 | ``` |
| 256 | 254 | |
| 257 | 255 | This is planning shape, not final schema. The important requirement is that hosts can opt into layers independently while still validating that the selected layers are compatible. |
| @@ -264,13 +262,13 @@ Extensions declare what the plugin contributes at a product level. · 6 unmodified lines | ||
| 264 | 262 | |
| 265 | 263 | ```ts |
| 266 | 264 | type PluginExtensions = { |
| 267 | − sources?: SourceExtensionDeclaration[] | |
| 268 | − secretStores?: SecretStoreExtensionDeclaration[] | |
| 269 | − tools?: ToolExtensionDeclaration[] | |
| 270 | − routes?: RouteExtensionDeclaration[] | |
| 271 | − uiSurfaces?: UiSurfaceDeclaration[] | |
| 272 | − backgroundServices?: BackgroundServiceDeclaration[] | |
| 273 | −} | |
| 265 | + sources?: SourceExtensionDeclaration[]; | |
| 266 | + secretStores?: SecretStoreExtensionDeclaration[]; | |
| 267 | + tools?: ToolExtensionDeclaration[]; | |
| 268 | + routes?: RouteExtensionDeclaration[]; | |
| 269 | + uiSurfaces?: UiSurfaceDeclaration[]; | |
| 270 | + backgroundServices?: BackgroundServiceDeclaration[]; | |
| 271 | +}; | |
| 274 | 272 | ``` |
| 275 | 273 | |
| 276 | 274 | Some extension declarations may be fully static. Others may be partial declarations that are completed by plugin registration at load time. |
| @@ -283,16 +281,16 @@ Capabilities declare requested access. Grants are separate host state. · 6 unmodified lines | ||
| 283 | 281 | |
| 284 | 282 | ```ts |
| 285 | 283 | type PluginCapabilities = { |
| 286 | − tools?: ToolCapabilityDeclaration[] | |
| 287 | − sources?: SourceCapabilityDeclaration[] | |
| 288 | − secrets?: SecretCapabilityDeclaration[] | |
| 289 | − network?: NetworkCapabilityDeclaration[] | |
| 290 | − storage?: StorageCapabilityDeclaration[] | |
| 291 | − scopes?: ScopeCapabilityDeclaration[] | |
| 292 | − filesystem?: FilesystemCapabilityDeclaration[] | |
| 293 | − execution?: ExecutionCapabilityDeclaration[] | |
| 294 | − host?: HostCapabilityDeclaration[] | |
| 295 | −} | |
| 284 | + tools?: ToolCapabilityDeclaration[]; | |
| 285 | + sources?: SourceCapabilityDeclaration[]; | |
| 286 | + secrets?: SecretCapabilityDeclaration[]; | |
| 287 | + network?: NetworkCapabilityDeclaration[]; | |
| 288 | + storage?: StorageCapabilityDeclaration[]; | |
| 289 | + scopes?: ScopeCapabilityDeclaration[]; | |
| 290 | + filesystem?: FilesystemCapabilityDeclaration[]; | |
| 291 | + execution?: ExecutionCapabilityDeclaration[]; | |
| 292 | + host?: HostCapabilityDeclaration[]; | |
| 293 | +}; | |
| 296 | 294 | ``` |
| 297 | 295 | |
| 298 | 296 | Capabilities should be deny-by-default. If a capability is missing, the runtime should deny that access. |
| @@ -344,11 +342,11 @@ The manifest should let plugins declare storage needs separately from storage gr · 45 unmodified lines | ||
| 344 | 342 | ```ts |
| 345 | 343 | type PluginStorageDeclaration = { |
| 346 | 344 | collections?: Array<{ |
| 347 | − name: string | |
| 348 | − kind: "kv" | "document" | "sql" | |
| 349 | − description?: string | |
| 350 | − }> | |
| 351 | −} | |
| 345 | + name: string; | |
| 346 | + kind: "kv" | "document" | "sql"; | |
| 347 | + description?: string; | |
| 348 | + }>; | |
| 349 | +}; | |
| 352 | 350 | ``` |
| 353 | 351 | |
| 354 | 352 | Storage declarations help the host pre-create, migrate, display, or approve plugin storage. |
| @@ -364,18 +362,18 @@ Executor does not need a custom publishing flow immediately. npm, GitHub/git, an · 9 unmodified lines | ||
| 364 | 362 | ```ts |
| 365 | 363 | type PluginArtifacts = { |
| 366 | 364 | files?: Array<{ |
| 367 | − path: string | |
| 368 | − size?: number | |
| 369 | − sha256?: string | |
| 370 | − kind?: "code" | "asset" | "manifest" | "source-map" | |
| 371 | − }> | |
| 365 | + path: string; | |
| 366 | + size?: number; | |
| 367 | + sha256?: string; | |
| 368 | + kind?: "code" | "asset" | "manifest" | "source-map"; | |
| 369 | + }>; | |
| 372 | 370 | bundle?: { |
| 373 | − format: "esm" | |
| 374 | − main: string | |
| 375 | − size?: number | |
| 376 | − sha256?: string | |
| 377 | − } | |
| 378 | −} | |
| 371 | + format: "esm"; | |
| 372 | + main: string; | |
| 373 | + size?: number; | |
| 374 | + sha256?: string; | |
| 375 | + }; | |
| 376 | +}; | |
| 379 | 377 | ``` |
| 380 | 378 | |
| 381 | 379 | Cloud and local do not need to store cached artifacts the same way, but they should agree on immutable identity and verification where possible. |