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