diff --git a/notes/artifacts-workflows-and-generated-ui.md b/notes/artifacts-workflows-and-generated-ui.md deleted file mode 100644 index 468a0e15a..000000000 --- a/notes/artifacts-workflows-and-generated-ui.md +++ /dev/null @@ -1,619 +0,0 @@ -# Artifacts, Workflows, and Generated UI - -codex resume 019ddfa8-00c6-7502-ae32-ceb6d85b2976 - -Executor core should stay small: tools, sources, secrets, permissions, and -invocation. Workflows and generated UI are "just Git and code", but that -should not pull a versioned filesystem into the core SDK. - -The shared foundation is an optional artifact protocol package. - -```txt -@executor/sdk - tools - sources - secrets - permissions - invocation - elicitation - -@executor/artifacts - ArtifactStore contract - artifact refs - project/version/commit schemas - diff/file/change types - artifact errors - -@executor/artifacts-cloudflare - provider plugin for Cloudflare Artifacts - -@executor/artifacts-local-git - isomorphic-git-backed provider for tests and development - -@executor/workflows - feature plugin that requires ArtifactStore - -@executor/generative-ui - feature plugin that requires ArtifactStore -``` - -## Why not core - -Artifacts are not required to call tools safely. A user who only wants tool -execution should not install or configure artifact storage. - -Artifacts are still useful enough to standardize because multiple higher-level -features need the same versioned file-tree abstraction: - -- workflows -- generated components -- generated full UIs -- templates -- previews -- tests and fixtures -- agent work branches -- rollback and forks - -The protocol package is official, but it is not core. Use `@executor/artifacts` -for the protocol package. Provider plugins import its constants and implement -its contracts; feature plugins import the same constants and require them. - -## Artifact projects - -Do not make this workflow-specific. Model the generic thing as an artifact -project, with workflows as the first consumer. - -```ts -type ArtifactKind = "workflow" | "component" | "ui" | "template" | "connector" | "document"; - -type ArtifactProject = { - id: string; - ownerId: string; - kind: ArtifactKind; - name: string; - defaultBranch: string; - currentCommit: string; - createdAt: Date; - updatedAt: Date; -}; -``` - -The app database indexes product metadata and permissions. The artifact store -owns file trees and history. - -Database answers: - -- Which artifacts does this user or workspace own? -- Which commit is current or published? -- Who can read/write it? -- Which runs, previews, or deployments point at it? - -Artifact storage answers: - -- What files existed at commit X? -- What changed between two versions? -- Can an agent fork/edit this tree? -- Can we clone, export, replay, or roll back it? - -## File layout - -Every artifact project should have a manifest at the root. - -```txt -artifact.json -src/ -tests/ -fixtures/ -preview/ -``` - -Workflow artifact: - -```txt -artifact.json -src/workflows/support-triage.workflow.ts -src/steps/ -workflow.manifest.json -tests/workflow.test.ts -fixtures/sample-input.json -``` - -Generated UI artifact: - -```txt -artifact.json -src/App.tsx -src/components/ -src/styles.css -ui.manifest.json -tests/ -preview/ -``` - -Generated component artifact: - -```txt -artifact.json -src/index.tsx -src/demo.tsx -props.schema.json -component.manifest.json -tests/ -preview/ -``` - -## ArtifactStore shape - -Keep the protocol boring. No workflow concepts, UI concepts, Cloudflare -concepts, or agent concepts. - -```ts -export type ArtifactRef = { - projectId: string; - ref: string; // branch, tag, or commit -}; - -export type ArtifactCommit = { - id: string; - message: string; - parentIds: readonly string[]; - createdAt: Date; -}; - -export interface ArtifactStore { - readonly createProject: ( - input: CreateArtifactProjectInput, - ) => Effect.Effect; - - readonly readFile: (ref: ArtifactRef, path: string) => Effect.Effect; - - readonly writeFiles: ( - input: WriteArtifactFilesInput, - ) => Effect.Effect; - - readonly diff: (input: ArtifactDiffInput) => Effect.Effect; - - readonly forkProject: ( - input: ForkArtifactProjectInput, - ) => Effect.Effect; -} -``` - -Cloudflare Artifacts is the likely first production provider because it is -versioned file-tree storage that speaks Git and can be accessed from Workers, -REST, and Git clients. The Cloudflare package should implement the protocol, -not define the product model. - -`@executor/artifacts-local-git` should likely use `isomorphic-git`, not shell -out to a Git binary. Cloudflare Artifacts exposes standard Git smart HTTP -remotes, and Cloudflare documents `isomorphic-git` as working with Artifacts in -Workers when no Git binary or local disk is available. Using it locally gives -us one Git implementation path that can also work in Workers with an in-memory -filesystem. - -## Protocol provider selection - -Secret storage is the closest existing pattern. Secret providers register -unique provider keys, routes can pin a secret to a provider, and writes can -name a provider explicitly. Protocol providers should reuse that shape instead -of inventing a separate dependency system. - -The general shape: - -```ts -createExecutor({ - plugins: [artifactsLocal(), artifactsCloudflare(), workflows()], - protocols: { - "executor.artifacts.store": "artifacts-cloudflare", - }, -}); -``` - -Rules: - -- provider plugins expose a unique plugin/provider id -- protocol packages expose stable capability constants -- feature plugins require protocol capabilities by importing those constants -- if exactly one installed provider satisfies a required protocol, use it -- if multiple installed providers satisfy a required protocol, require explicit - selection in executor config -- optional protocol requirements can be absent without failing startup - -This is intentionally stricter than secret writes. Picking the "first writable" -secret provider is acceptable for convenience because each secret route is -pinned after creation. Picking the first artifact store would be risky because -workflows, generated UI, previews, and runs depend on stable file history. - -## Workflows - -Workflows should be a feature plugin, not core. - -The workflow plugin owns: - -- code indexing for `"use workflow"` files -- derived workflow graph/manifests -- code transforms for visual edits -- validation -- code generation -- run model -- runtime adapter -- workflow tools such as `workflows.create`, `workflows.update`, - `workflows.validate`, and `workflows.run` - -The artifact store owns: - -- versioned workflow files -- commit history -- forks -- diffs -- rollback - -Workflow code is canonical. The graph is a derived UI/index, not the source of -truth. - -```txt -code -> indexed graph -> visual editor -visual edit -> code transform -> commit -> re-index graph -commit -> run pinned to commit -> observable steps -``` - -Runs should always pin the artifact commit they executed. This gives -reproducibility, rollback, and a clean audit trail. - -`"use workflow"` and `"use step"` are the syntax that make code indexable: - -- workflow function = graph root -- awaited step calls = action nodes -- arguments = data mappings -- `if` statements = branches -- `Promise.all` = parallel branches -- `sleep`, hooks, or webhooks = wait/resume nodes -- return value = workflow output - -The manifest is derived, like an index or lockfile. It can be stored for fast -UI rendering/search, but source files are canonical. - -## Generated UI - -Generated UI and generated components should use the same artifact foundation. -Source files are canonical; manifests and previews are derived. - -The generative UI plugin owns: - -- UI/component manifests -- preview build pipeline -- test/validation pipeline -- publish/deploy hooks -- generated UI tools - -The shared artifact protocol means workflows, components, and full UIs can all -use the same versioning, forking, diffing, preview, and rollback primitives. - -## Zapier-style product foundation - -The workflow plugin is the start of a Zapier competitor, but the first landing -should only establish the spine: - -1. artifact protocol and provider -2. workflow code conventions around `"use workflow"` and `"use step"` -3. code indexer that derives a graph/manifest -4. connector/action metadata for humans -5. validation pipeline -6. run records pinned to artifact commits -7. basic run history and step logs - -Later product layers can add triggers, polling cursors, schedules, templates, -branching, approvals, retries, replay, connector marketplaces, and richer data -mapping. - -The durable `"use workflow"` model is the right canonical representation, but -the Workflow SDK "world" concept should not leak to users. Users see -workflows, runs, steps, approvals, waits, and logs. - -## TypeScript sketch - -Protocol package: - -```ts -// @executor/artifacts -import type { Effect } from "effect"; -import type { PluginProtocolProvider } from "@executor/sdk"; - -export const ArtifactStoreProtocol = { - key: "executor.artifacts.store", - label: "Artifact Store", -} as const satisfies PluginProtocolProvider<"executor.artifacts.store">; - -export type ArtifactRef = { - projectId: string; - ref: string; // branch, tag, or commit -}; - -export type ArtifactProject = { - id: string; - kind: "workflow" | "component" | "ui" | "template" | "connector" | "document"; - name: string; - defaultBranch: string; - currentCommit: string; -}; - -export type ArtifactCommit = { - id: string; - message: string; - parentIds: readonly string[]; - createdAt: Date; -}; - -export type ArtifactFileChange = - | { type: "put"; path: string; contents: Uint8Array | string } - | { type: "delete"; path: string }; - -export interface ArtifactStore { - readonly createProject: (input: { - kind: ArtifactProject["kind"]; - name: string; - }) => Effect.Effect; - - readonly readFile: (ref: ArtifactRef, path: string) => Effect.Effect; - - readonly writeFiles: (input: { - projectId: string; - baseRef: string; - branch: string; - message: string; - changes: readonly ArtifactFileChange[]; - }) => Effect.Effect; - - readonly diff: (input: { - projectId: string; - baseRef: string; - headRef: string; - }) => Effect.Effect; - - readonly forkProject: (input: { - projectId: string; - name: string; - }) => Effect.Effect; -} - -export type ArtifactsProtocolCtx = { - readonly artifacts: ArtifactStore; -}; -``` - -Provider plugin: - -```ts -// @executor/artifacts-local-git -import { definePlugin } from "@executor/sdk"; -import { ArtifactStoreProtocol, type ArtifactStore } from "@executor/artifacts"; - -export const artifactsLocalGitPlugin = (options: { rootDir: string }) => - definePlugin(() => { - const artifacts: ArtifactStore = makeIsomorphicGitArtifactStore(options); - - return { - id: "artifacts-local-git" as const, - provides: [ArtifactStoreProtocol], - storage: () => ({}), - protocols: () => ({ - artifacts, - }), - }; - })(); -``` - -Feature plugin: - -```ts -// @executor/workflows -import { definePlugin, type PluginCtx } from "@executor/sdk"; -import { ArtifactStoreProtocol, type ArtifactsProtocolCtx } from "@executor/artifacts"; - -type WorkflowCtx = PluginCtx; - -export const workflowsPlugin = definePlugin(() => ({ - id: "workflows" as const, - requires: [{ ...ArtifactStoreProtocol, reason: "persist workflow code" }], - storage: () => ({}), - - extension: (ctx: WorkflowCtx<{}>) => ({ - create: (input: { name: string; files: readonly ArtifactFileChange[] }) => - Effect.gen(function* () { - const project = yield* ctx.artifacts.createProject({ - kind: "workflow", - name: input.name, - }); - - const commit = yield* ctx.artifacts.writeFiles({ - projectId: project.id, - baseRef: project.defaultBranch, - branch: project.defaultBranch, - message: `Create workflow ${input.name}`, - changes: input.files, - }); - - const manifest = yield* indexWorkflowCode({ - projectId: project.id, - ref: commit.id, - artifacts: ctx.artifacts, - }); - - return { project, commit, manifest }; - }), - }), -})); -``` - -Executor composition: - -```ts -const executor = - yield * - createExecutor({ - scopes: [userScope], - adapter, - blobs, - plugins: [ - artifactsLocalGitPlugin({ rootDir: ".executor-artifacts" }), - workflowsPlugin(), - ] as const, - protocols: { - "executor.artifacts.store": "artifacts-local-git", - }, - }); - -const workflow = - yield * - executor.workflows.create({ - name: "Support triage", - files: [ - { - type: "put", - path: "artifact.json", - contents: JSON.stringify({ - schemaVersion: 1, - kind: "workflow", - entrypoints: ["src/workflows/support-triage.workflow.ts"], - }), - }, - { - type: "put", - path: "src/workflows/support-triage.workflow.ts", - contents: supportTriageWorkflowSource, - }, - { - type: "put", - path: "src/steps/summarize.ts", - contents: summarizeStepSource, - }, - ], - }); -``` - -Generated workflow code: - -```ts -// src/workflows/support-triage.workflow.ts -import { createLinearIssue } from "../steps/linear"; -import { summarizeTicket } from "../steps/summarize"; - -export async function supportTriage(input: SupportEmail) { - "use workflow"; - - const summary = await summarizeTicket(input); - - if (summary.priority === "high") { - return await createLinearIssue({ - title: summary.title, - body: summary.body, - }); - } - - return { status: "ignored", summary }; -} -``` - -```ts -// src/steps/summarize.ts -export async function summarizeTicket(input: SupportEmail) { - "use step"; - - return await tools.openai.responses.create({ - model: "gpt-5.1", - input: `Summarize and classify: ${input.body}`, - }); -} -``` - -E2E flow: - -```ts -it.effect("creates a code-first workflow artifact and indexes a visual graph", () => - Effect.gen(function* () { - const executor = yield* createExecutor({ - scopes: [testScope], - adapter, - blobs, - plugins: [artifactsLocalGitPlugin({ rootDir: tempDir }), workflowsPlugin()] as const, - protocols: { - "executor.artifacts.store": "artifacts-local-git", - }, - }); - - const created = yield* executor.workflows.create({ - name: "Support triage", - files: [ - { - type: "put", - path: "artifact.json", - contents: JSON.stringify({ - schemaVersion: 1, - kind: "workflow", - entrypoints: ["src/workflows/support-triage.workflow.ts"], - }), - }, - { - type: "put", - path: "src/workflows/support-triage.workflow.ts", - contents: supportTriageWorkflowSource, - }, - ], - }); - - const source = yield* executor.artifacts.readFile( - { projectId: created.project.id, ref: created.commit.id }, - "src/workflows/support-triage.workflow.ts", - ); - expect(new TextDecoder().decode(source)).toContain('"use workflow"'); - - expect(created.manifest.entrypoints).toEqual(["src/workflows/support-triage.workflow.ts"]); - expect(created.manifest.graph.nodes.map((node) => node.kind)).toContain("step"); - }), -); -``` - -## Todos - -- [ ] Rename protocol dependency keys to namespaced constants such as - `executor.artifacts.store`. -- [ ] Extend plugin dependency metadata so protocol constants can carry typed - context fragments. -- [ ] Add `PluginCtx` and support direct protocol fields - such as `ctx.artifacts` without adding artifacts to base core ctx. -- [ ] Add executor-level protocol provider selection config. -- [ ] Fail startup when multiple providers satisfy a required protocol and no - explicit provider selection exists. -- [ ] Create `@executor/artifacts` protocol package. -- [ ] Define `ArtifactStore`, artifact refs, commits, diffs, file changes, - project metadata, manifest schema, and artifact errors. -- [ ] Export `ArtifactStoreProtocol` and `ArtifactsProtocolCtx` from - `@executor/artifacts`. -- [ ] Create `@executor/artifacts-local-git` using `isomorphic-git`. -- [ ] Add local Git provider tests for create project, write files, read files, - diff, and fork. -- [ ] Create `@executor/artifacts-cloudflare` provider against Cloudflare - Artifacts. -- [ ] Add Cloudflare provider support for repo creation, commit writes, refs, - token handling, and clone/fetch/push flows. -- [ ] Define required `artifact.json` root manifest format. -- [ ] Add manifest validation shared by artifact providers/features. -- [ ] Create `@executor/workflows` feature plugin. -- [ ] Define code-first workflow conventions for `"use workflow"` entrypoints - and `"use step"` functions. -- [ ] Build a workflow code indexer that derives graph/manifest data from - source files. -- [ ] Add validation for workflow entrypoints, step references, tool calls, - required connector metadata, and artifact manifest consistency. -- [ ] Store derived `workflow.manifest.json` as an index, not canonical source. -- [ ] Add workflow tools for create, update, validate, inspect, and preview. -- [ ] Add code transform path for visual workflow edits. -- [ ] Add run records that pin artifact project id, provider id, ref, and - commit. -- [ ] Defer full durable execution until build/runtime wiring for generated - `"use workflow"` artifacts is clear. -- [ ] Create `@executor/generative-ui` feature plugin after artifact protocol is - stable. -- [ ] Define generated UI/component manifests as derived indexes over source - code. -- [ ] Add preview/build/test pipeline hooks for generated UI artifacts. diff --git a/notes/cloud-workspaces-and-global-sources-plan.md b/notes/cloud-workspaces-and-global-sources-plan.md deleted file mode 100644 index fab93c284..000000000 --- a/notes/cloud-workspaces-and-global-sources-plan.md +++ /dev/null @@ -1,447 +0,0 @@ -# Cloud Workspaces and Global Sources Plan - -Date: 2026-05-04 -Status: planning - -## Summary - -Cloud should introduce workspaces without making every organization create -one. The organization/global context remains a first-class working context for -org-wide sources, secrets, connections, and policies. Workspaces are optional -project contexts layered on top of that global context. - -The product model is: - -- `/:org` is the global organization context. -- `/:org/:workspace` is a workspace context. -- Global sources are available immediately in every workspace. -- Workspace sources can shadow global sources by namespace. -- Secrets, connections, and policies resolve through the active scope stack. -- Writes always name an explicit target scope. The server and SDK should not - invent default write targets. - -The user-facing explanation should avoid "scopes" where possible: - -> Sources define capability. Secrets decide how that capability authenticates -> in the current context. - -## Goals - -- Make workspaces the project/context concept for cloud. -- Keep org/global useful without creating a fake default workspace. -- Preserve and extend the existing scope stack model. -- Make inherited global sources clear in the UI. -- Use URL context as the source of truth for org/workspace selection. -- Remove hidden request context headers. -- Keep v1 small: create workspaces, route by context, and preserve global - source inheritance. No workspace permissions, deletion, granular usage - tracking, or full personal override UI in the first pass. - -## Non-goals for v1 - -- Workspace-specific membership or roles. Every org member can access every - workspace. -- Workspace deletion. -- Workspace rename UI, though slugs/ids should allow it later. -- Org handle edit UI, though handles should allow it later. -- Per-workspace billing or usage tracking. Usage still rolls up to org. -- Full UI for every personal override path. The data model and API should leave - a path to `user-workspace` and `user-org` overrides over time. -- Backward-compatible web/API routes, except `/mcp` compatibility. - -## Product Model - -### Organization/global context - -The org/global context is not only an admin area. It is a working context. - -At `/:org`, users see global org sources and org-level resources. Sources added -here are inherited by every workspace immediately. Existing org-scoped data -maps naturally to this context. - -Org/global has the scope stack: - -```txt -user-org -> org -``` - -### Workspace context - -A workspace is a narrower project context inside an org. It layers over global. - -At `/:org/:workspace`, users see workspace sources plus inherited global -sources. Sources added here are local to this workspace. If a workspace source -uses the same namespace as a global source, the workspace source shadows the -global one in that workspace. - -Workspace has the scope stack: - -```txt -user-workspace -> workspace -> user-org -> org -``` - -### Global sources - -Global sources are worth keeping because source definitions and credentials are -separate concerns. - -An org can define one global `stripe` source once. Each workspace and user can -then resolve credentials through the active scope stack: - -- `user-workspace`: my credential for this workspace. -- `workspace`: shared credential for this workspace. -- `user-org`: my personal credential across this org. -- `org`: shared org/global credential. - -This avoids duplicating source definitions while still letting secrets live at -the right level. - -### Shadowing - -Scope precedence decides effective behavior. - -In a workspace, source/tool invocation uses the merged stack. If a workspace -source and global source have the same namespace/tool ids, the workspace source -wins. - -The UI should still show the shadowed global source as disabled/muted with an -`Overridden` state so users understand why it is not effective in that -workspace. - -## Routing - -Web routes should be context-addressed: - -```txt -/:org -/:org/sources/:namespace -/:org/connections -/:org/secrets -/:org/policies - -/:org/:workspace -/:org/:workspace/sources/:namespace -/:org/:workspace/connections -/:org/:workspace/secrets -/:org/:workspace/policies -``` - -Use a reserved org-admin segment for pages that are not workspace contexts: - -```txt -/:org/-/billing -/:org/-/settings -/:org/-/workspaces -``` - -This keeps `/:org/:workspace` clean and avoids a large reserved-word list for -workspace slugs. - -`/` should redirect to the signed-in user's current or first org global -context, not force a workspace. - -Breaking old web routes is acceptable. Prefer less compatibility code. - -## API Context - -The API should also be context-addressed. - -```txt -/api/:org/... -/api/:org/:workspace/... -``` - -The URL context determines which executor scope stack the server builds. Reads -operate against that stack. Writes still pass an explicit target scope id in -the payload/path; the URL context only bounds what targets are legal. - -Remove the current hidden context header pattern. Session identity proves the -user; URL org/workspace resolves and authorizes the context. - -The session should no longer be treated as the source of truth for "active org" -when routing protected app/API requests. The URL org handle is the context. -Every request authorizes that the signed-in user is an active member of that -org. Workspace requests also verify that the workspace belongs to the org. - -## MCP - -MCP should get explicit context URLs too: - -```txt -/:org/mcp -/:org/:workspace/mcp -``` - -Keep `/mcp` as a compatibility fallback. It should resolve to the signed-in -user's org/global context. Do not use "last active workspace" as hidden state. - -OAuth and MCP-related flows should preserve the context path so callbacks land -back in the same org/workspace context. - -## UI Plan - -Use the same shell and left nav shape for global and workspace contexts. The -context switcher shows `Global` alongside workspaces: - -```txt -Acme / Global -Acme / Billing API -``` - -The switcher should show `Global` pinned at the top, then a separator, then -workspaces. Workspaces can be created from the switcher in v1. - -Left nav stays stable across contexts. Workspaces should not appear in the -left nav; they live in the context switcher. - -When in global context, billing/admin links are available. In workspace -context, the main working nav remains focused on sources, connections, secrets, -and policies. - -### Sources sidebar - -In global context, the sources sidebar shows global sources. - -In workspace context, split the sources list into: - -- Workspace sources. -- Global sources. - -Inherited global sources are visible in workspace context. If a workspace -source shadows a global source, show the global source as muted/disabled with -an `Overridden` state. - -### Source detail - -Inherited global source detail can open inside the workspace URL so users can -inspect effective credentials and connect workspace-specific credentials -without losing context. - -Editing the inherited source definition should only happen from global context. -Workspace detail pages should link to `/:org/sources/:namespace` for editing -the global source definition. - -### Source creation - -Source definition writes should target only: - -- `org` / Global. -- current `workspace`. - -Personal source definitions are not part of v1. Personal scopes are for -credentials, connections, policies, and future overrides. - -Add-source forms should show a visible target selector and pass the selected -target explicitly. - -### Secrets and connections - -Secrets/connections should initially show the effective credential state by -default. A fuller grouped view can come later. - -The full credential storage stack is: - -- Only me in this workspace: `user-workspace`. -- Everyone in this workspace: `workspace`. -- Only me across this org: `user-org`. -- Everyone in this org: `org`. - -The client may choose a visible default, but every write must pass the chosen -target scope explicitly. - -### Policies - -Policies should have a path to the full stack: - -- `user-workspace` -- `workspace` -- `user-org` -- `org` - -The API/storage model should support this. The v1 UI can expose a narrower -surface if needed, but should not paint the product into an org/workspace-only -corner. - -## IDs, Handles, and Slugs - -Use stable ids internally and mutable URL handles/slugs externally. - -- Org URL segment is a local unique handle. -- Workspace URL segment is a slug unique within the org. -- Handles/slugs are generated automatically from names in v1. -- Collision handling appends a suffix. -- Do not use handle/slug as a primary key or scope id. -- Org handles and workspace slugs should be editable later, with redirects - added later if needed. - -Use prefixed random ids for new local entities, following the Unkey-style ID -helper pattern: - -```txt -workspace_ -``` - -WorkOS user/org ids can stay as identity anchors. Do not invent local org/user -primary ids unless the product needs to move off WorkOS ids. - -Scope ids should be deterministic and prefixed: - -```txt -org_ -workspace_ -user_org__ -user_workspace__ -``` - -Existing raw org scope ids should be handled in the one-shot migration so new -code has one org-scope convention. - -## Data Model - -Add local organization handle support: - -```txt -organizations.handle text not null unique -``` - -Add workspaces: - -```txt -workspaces - id text primary key - organization_id text not null references organizations(id) - slug text not null - name text not null - created_at timestamptz not null default now() - updated_at timestamptz not null default now() - -unique (organization_id, slug) -``` - -Workspace names do not need to be unique. Slugs are unique within an org. - -No workspace membership table in v1. Org membership grants access to all -workspaces. - -No default workspace. Existing org/global resources stay org/global. - -## Migration - -A one-shot migration is acceptable because cloud has few users. - -Migration should: - -- Add org handles and backfill from org names with collision suffixes. -- Add workspaces table. -- Move existing raw org scope ids to prefixed `org_` scope ids across - scoped tables, unless we decide to preserve raw ids as a legacy read path. -- Preserve existing org-level sources as global sources. -- Preserve existing user-org personal data under the new - `user_org__` convention if those rows exist. - -Prefer migration over long-term legacy compatibility. - -## Executor Construction - -Cloud should construct executors from URL-resolved context: - -Global: - -```txt -[ - Scope(user_org__, "Me / "), - Scope(org_, " Global") -] -``` - -Workspace: - -```txt -[ - Scope(user_workspace__, "Me / "), - Scope(workspace_, ""), - Scope(user_org__, "Me / "), - Scope(org_, " Global") -] -``` - -The scope API should return both: - -- The active display/write context, usually global or workspace. -- The full stack for UI storage-target selectors and inherited resource - display. - -Do not rely on `executor.scopes.at(-1)` as "the current write scope" once -workspace context exists. The active source-definition scope is `org` in global -context and `workspace` in workspace context. - -## Explicit Write Target Invariant - -All scoped writes must pass a target scope explicitly. - -The client may select a visible default, but the server and SDK should not -guess. This applies to: - -- Source definitions. -- Secrets. -- Connections. -- Policies. -- OAuth token writes. -- Plugin-owned scoped side tables. - -The context URL bounds legal targets. A workspace-context request can target -any scope in its stack. A global-context request can target `user-org` or -`org`. - -## Implementation Slices - -### 1. Domain and ids - -- Add ID helper for local prefixed ids. -- Add org handle generation. -- Add workspace schema and migration. -- Add deterministic scope id helpers. -- Add URL handle/slug resolvers. - -### 2. Context-addressed web/API routing - -- Move app routes under `/:org` and `/:org/:workspace`. -- Move protected API under `/api/:org` and `/api/:org/:workspace`. -- Remove hidden context headers. -- Resolve org/workspace from URL in protected middleware. -- Build global or workspace executor scope stack from resolved context. - -### 3. Scope API and client context - -- Update scope info to expose active context plus full stack. -- Update `ScopeProvider` consumers to use active source-definition scope for - source reads/writes. -- Keep full stack available for credential/policy target selectors. - -### 4. Sources UI - -- Update context switcher with `Global` plus workspaces. -- Add create-workspace flow in switcher. -- Split workspace source sidebar into workspace and global sections. -- Show shadowed global sources as overridden. -- Add visible source target selector for add-source flows. -- Route global-source definition edits through global context. - -### 5. Secrets, connections, policies - -- Make write target selection visible and explicit. -- Start with effective credential display where simplest. -- Preserve API/storage path to full-stack personal overrides. - -### 6. MCP and OAuth context - -- Add `/:org/mcp` and `/:org/:workspace/mcp`. -- Keep `/mcp` fallback to signed-in org/global. -- Preserve context path in OAuth session/callback payloads. - -## Open Details - -- Exact generated slug collision format. -- Exact labels for credential target selector. -- Whether the first implementation exposes all policy target scopes in UI or - only wires API/storage support. diff --git a/notes/credential-slot-ui-refactor.md b/notes/credential-slot-ui-refactor.md deleted file mode 100644 index 6cca7844e..000000000 --- a/notes/credential-slot-ui-refactor.md +++ /dev/null @@ -1,53 +0,0 @@ -# Credential Slot UI Refactor Notes - -We want to keep the plugin model, but stop making each source UI reinvent the -same credential concepts. OpenAPI, MCP, and GraphQL should still own their -source-specific setup, probing, parsing, auth capabilities, and save payloads. -The shared layer should only cover credential slots, binding values, secret -selection, OAuth connection controls, and product language around ownership. - -## Product Direction - -- A source has a shared authentication method. Users may be able to override - credential values for that method, but they cannot switch the method per - person unless the backend model explicitly supports that. -- Secrets are reusable values that live at a personal or organization ownership - level. -- Source bindings attach a secret, literal value, or OAuth connection to a - source slot for a personal or organization usage level. -- The UI should communicate these as separate choices: - - where a new secret is saved; - - who uses a source credential binding; - - where an OAuth connection/token is saved. -- Avoid product copy that exposes "scope" unless it is a developer/debug - surface. - -## Refactor Direction - -- Do not introduce a generic HTTP source model. -- Do introduce shared credential-slot UI primitives that plugins compose. -- Plugins should adapt their own source config into shared slot descriptors and - save the result through plugin-owned atoms/API calls. -- Add and edit flows should reuse the same credential editors where the product - behavior is the same. -- OpenAPI is the largest cleanup target. MCP and GraphQL are smaller but useful - for extracting the lower-risk shared pieces first. - -## Likely Shared Concepts - -- Convert configured credential values plus binding refs into editor state. -- Preserve per-row binding usage scope and selected secret ownership scope. -- Render secret-backed headers and query params with the same picker, preview, - and "Used by" controls. -- Render OAuth connect/reconnect controls with the same saved-to dropdown and - validity styling, while keeping plugin-specific OAuth payload construction. -- Report effective vs explicit credential state without each plugin rendering - bespoke debug/product copy. - -## Guardrails - -- Do not collapse plugin-specific source models into one abstraction. -- Do not make MCP stdio, MCP remote probing, GraphQL introspection, and OpenAPI - spec parsing fit a fake common lifecycle. -- Prefer small shared adapters first, then replace bespoke OpenAPI edit pieces - after the shared API has proven itself in MCP/GraphQL. diff --git a/notes/desktop-daemon-ownership-wedge.md b/notes/desktop-daemon-ownership-wedge.md deleted file mode 100644 index 7ffa56ce2..000000000 --- a/notes/desktop-daemon-ownership-wedge.md +++ /dev/null @@ -1,90 +0,0 @@ -# Desktop daemon attach/spawn ownership wedge - -Date: 2026-06-22 -Status: fix landed (attach-on-conflict), one macOS follow-up open - -## Symptom - -Desktop app "crashes": the window is up but every request fails / it sits on -the sidecar crash screen and "Restart server" does nothing. Logs show, on a -loop: - -``` -(sidecar) A local Executor cli-daemon is already running at http://localhost:4789 (pid N). - It owns the current data directory: /Users//.executor - Stop it before starting another local server. -Failed to start executor sidecar Error: Sidecar exited before ready (code=1 ...) -``` - -## Root cause - -The desktop and a standalone `executor daemon run` (the CLI daemon, e.g. a -globally-installed `executor`) both default to owning the same scope dir -`~/.executor`. Only one process may own it at a time (enforced by the -`server-control/server.json` manifest + the per-scope `daemon-*.json` pointers). - -`boot()` attaches to an already-running daemon before spawning, so a cold start -is fine. But the two recovery/fallback paths did NOT attach, they only spawned: - -- `restartSidecarAndReload()` (non-supervised branch) -> `startWithCurrentSettings()` -> `startSidecar()` -- `boot()` fallback when `ensureSupervisedConnection()` returned null - -So the incident sequence was: - -1. App spawns its own managed sidecar (`kind: desktop-sidecar`). -2. That sidecar dies (in prod: SIGINT/code 130, coincident with a CLI - `executor daemon run` starting and grabbing the port/scope). -3. The CLI daemon now owns `~/.executor` (`kind: cli-daemon`, healthy). -4. The app tries to restart -> re-spawns a second server -> dies on the scope - lock -> treated as fatal -> wedged on the crash screen until the CLI daemon - is killed by hand. - -`replaceSupervisedDaemonForDesktop()` already encodes the right instinct ("on -failure, attach instead of dying"), it just was not applied to the spawn -fallback. - -## Fix - -`apps/desktop/src/main/index.ts`, `startWithCurrentSettings()`: when a spawn -aborts because the data dir is already owned (`already running` / `owns the -current data directory`), poll `attachToSupervisedDaemon()` for ~10s and adopt -the running cli-daemon instead of surfacing a fatal error. This is the single -chokepoint for both the boot-fallback and restart paths. The short poll also -rides out the health-probe vs. scope-lock race (owner alive but `/api/health` -not yet `ok`). - -Also added `EXECUTOR_TEST_SKIP_BACKGROUND_SERVICE=1` (mirrors -`EXECUTOR_TEST_AUTO_CONFIRM_RESET`) so the packaged app can be driven headlessly -without the first-run background-service dialog. - -## Reproductions - -- `e2e/desktop/sidecar-attach-conflict.test.ts` (dev project, deterministic): - a CLI daemon owns the temp HOME's `.executor`; the dev app must come up by - attaching instead of dying on the scope lock. Dev boot always takes the - managed-spawn path, so it exercises the spawn -> attach fallback directly. - Needs a GUI session (or Linux+Xvfb) to run; it cannot run from a non-Aqua - background shell. - -- `e2e/desktop-packaged/linux-vm/` (headless real app): builds the linux-arm64 - bundle, runs it under Xvfb in a container, and replays the full incident - (spawn own sidecar -> kill it -> CLI daemon takes over -> click Restart) over - CDP. Exit 0 = recovered by attaching; non-zero = wedged. See its README. - -## Open follow-up: macOS launchd supervision is broken on at least one machine - -Independent of the wedge, the same logs show the supervised-service install -failing on every boot: - -``` -launchctl bootstrap failed (exit 5): Bootstrap failed: 5: Input/output error -launchctl kickstart failed (exit 113): Could not find service "sh.executor.daemon" in domain for user gui: 501 -``` - -So `ensureSupervisedConnection()` can never promote a daemon to a launchd -service; the app permanently runs in the fragile attach/spawn-ad-hoc mode where -the wedge was reachable. This is macOS-specific (not reproducible under the -Linux+Xvfb harness) and is NOT addressed by the attach-on-conflict fix. Worth a -separate investigation: why `bootstrap` returns EIO and why the `sh.executor.daemon` -unit is absent at kickstart time (LaunchAgent plist not written? wrong domain -target? gui/501 vs system domain?). diff --git a/notes/dynamic-plugin-loading-v1.md b/notes/dynamic-plugin-loading-v1.md deleted file mode 100644 index 38de04e8a..000000000 --- a/notes/dynamic-plugin-loading-v1.md +++ /dev/null @@ -1,1029 +0,0 @@ -# Dynamic Plugin Loading — v1 - -Date: 2026-05-02 (revised) - -> **Status: shipped.** The implementation lives on `main`. This doc was -> revised after the fact to match what actually shipped — three things -> changed from the original design: -> -> 1. `handlers(self) => Layer` became late-binding `handlers() => Layer` -> plus a `extensionService` Service tag on the spec. Cloud's -> per-request executor pattern needed the Tag to be satisfied at -> request time, not bake-in time. Local satisfies it eagerly via -> `composePluginHandlers`, cloud satisfies it per request via -> `providePluginExtensions`. -> 2. `` was added in `@executor-js/sdk/client` -> so frontend pages read `useSourcePlugins()` / `useSecretProviderPlugins()` -> / `useClientPlugins()` from React context instead of taking -> plugin lists as props. Routes/shells stop importing per-app -> aggregator files. -> 3. `SourcePlugin` and `SecretProviderPlugin` types moved to -> `@executor-js/sdk/client` and are populated as fields on -> `defineClientPlugin({ sourcePlugin, secretProviderPlugin })` so -> everything frontend-facing flows through `virtual:executor/plugins-client`. - -## Context - -Before this work, plugins were workspace deps statically imported in -`apps/local/src/server/executor.ts` inside a `createLocalPlugins()` -factory; the cloud app had its own equivalent in -`apps/cloud/src/services/executor.ts`. They register secret providers, -connection providers, and dynamic tools through `definePlugin`. The DX -of authoring one was good — the problem was that adding a plugin meant -editing host source. The goal: install plugins from npm and pick them -up by editing config alone. - -The existing in-process plugin model already gets a lot right and we want to -keep it: Effect-native everything, end-to-end type inference from schema → -storage → extension → consumer (`executor[pluginId]`), scope-aware ctx, closure -methods, zero-allocation static tools delegating via `staticSources(self) => -[...]`. Whatever changes here must preserve those properties. - -What's missing is registration of three new surfaces from a plugin: - -- **API routes** — HTTP endpoints owned by the plugin, contributed as an - `HttpApiGroup` so they compose into the host's existing typed `HttpApi` -- **Frontend** — pages/widgets/components contributed to the host UI, with - reactive `AtomHttpApi`-backed clients matching the existing `toolsAtom` / - `sourcesAtom` pattern in `packages/react/src/api/atoms.tsx` -- **SDK** — already present via `extension`; the new HTTP routes give the - frontend a typed reactive client without hand-written fetch glue - -## Goal and non-goals - -**v1 goals:** - -- A plugin is a single npm package. `bun add @executor-js/plugin-foo`, - add it to `executor.config.ts`, restart, and it works. -- Plugins can register: extension methods (today), API routes (new), - frontend pages/widgets (new). -- Frontend half gets a typed reactive client from the plugin's `HttpApiGroup` - via the same `AtomHttpApi` pattern the core uses today. -- A plugin author can write the whole thing importing only from - `@executor-js/sdk` (and `@executor-js/sdk/client` on the frontend). Effect - imports are optional — for authors who want them, not required for those - who don't. -- `executor.config.ts` is the single source of truth — same file consumed by - the schema-gen CLI and the runtime. -- Type inference end-to-end stays intact (`executor.foo.method()` autocompletes). - -**Non-goals for v1:** - -- Cloud / multi-tenant deployment. -- Electron desktop dynamism. Desktop builds bake plugins at build time. -- Sandboxing, capability enforcement, marketplace, signing. -- Hot reload of plugin code (restart is fine). -- Loading plugins by string spec at runtime (we use import-and-call instead; - see decision #1). - -## Reference research summary - -Surveyed five plugin systems in `.reference/`: - -- **pi-mono** — filesystem scanning + jiti, factory function + ExtensionAPI. - Two-phase load (registration vs. action) is a clean idea. -- **opencode** — string specs in JSONC, dynamic `import()`, hook trigger - pattern with `(input, output)` mutation. -- **openclaw** — manifest-first control plane (`openclaw.plugin.json` declares - capabilities statically so the host can plan activation without loading code). -- **emdash** — **closest match to our setup.** Astro/Vite + React, plugins are - npm packages with separate `./` and `./admin` exports, registered via - import-and-call in `astro.config.mjs`. `definePlugin({ id, hooks, routes, -admin })` declarative shape. -- **dynamic-software** — most ambitious; Cloudflare Worker Loader for cloud - isolation, iframe + postMessage RPC for UI, Proxy-based typed API client. - -The pattern that fits us cleanest is emdash's: import-and-call in a config -file, single npm package with separate server/client exports, host integrates -via Vite. We don't need dynamic-software's Proxy RPC client because -`@effect/platform` already gives us `HttpApiClient` plus `AtomHttpApi` for -the reactive React side — both already used heavily in -`packages/core/api/src/api.ts` and `packages/react/src/api/`. - -## Decisions - -### 1. Config is import-and-call, not string specs - -`executor.config.ts` holds real imports of plugin factory functions. Type -inference flows naturally; no codegen step. - -```ts -// apps/local/executor.config.ts (after) -import { defineExecutorConfig } from "@executor-js/sdk"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; -import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; - -export default defineExecutorConfig({ - dialect: "sqlite", - plugins: [ - openApiPlugin(), - mcpPlugin({ dangerouslyAllowStdioMCP: true }), - fileSecretsPlugin(), - ] as const, -}); -``` - -"Dynamic" means npm-installable, not load-by-string-name. Same model as emdash. -Codegen-based string specs (TanStack Router style) deferred to later if remote -config becomes a need. - -### 2. Plugin = single npm package, two exports - -```jsonc -// @executor-js/plugin-foo/package.json -{ - "name": "@executor-js/plugin-foo", - "type": "module", - "exports": { - "./server": "./dist/server.js", - "./client": "./dist/client.js", - }, - "executor": { - "id": "foo", - "version": "0.1.0", - }, -} -``` - -``` -@executor-js/plugin-foo/ -├── package.json -├── src/ -│ ├── server.ts # Effect, Node deps, definePlugin -│ ├── client.tsx # React, defineClientPlugin -│ └── shared.ts # Schemas, types shared across the boundary -└── dist/ -``` - -Strict separation: server bundle never imports React, client bundle never -imports Effect/Node modules. Shared types live in `src/shared.ts` and are -imported by both halves. - -### 3. Extend `PluginSpec` with optional `routes`; add parallel `defineClientPlugin` - -Server side: keep `definePlugin`. Add optional `routes` field (the -`HttpApiGroup`) and `handlers` field (the typed `Layer`). No frontend -concepts on the server side. - -Client side: separate primitive `defineClientPlugin` lives in -`@executor-js/sdk/client`. Can only be imported in the `./client` entry, so -React types never leak into server bundles. - -**Layering — extension is the canonical SDK; routes/handlers is optional HTTP transport.** -The HTTP layer is _not a peer_ of the SDK; it's a transport over it. Plugin -authors should treat extension as the implementation and write handlers as -thin wrappers that delegate via the `self` parameter (same pattern as -`staticSources(self) => [...]` already in the codebase). This keeps: - -- a single source of truth for the plugin's behavior -- in-process callers paying zero serialization cost -- HTTP callers getting auth/scope/observability middleware -- error contracts identical across the two surfaces - -Three plugin shapes fall out of this layering: - -- **SDK-only.** Pure programmatic. No `routes`, no `handlers`, no `./client` - export. Examples: file-secrets, keychain, onepassword, anything that's a - utility for other plugins or scripts. CLI/embedded consumers use - `executor..method()`. Vite plugin notices no `./client` and skips the - plugin in the frontend bundle entirely. -- **Both.** Extension _and_ routes/handlers _and_ a `./client`. Examples: - openapi, mcp, anything that needs a frontend. Routes are thin wrappers - over extension methods. -- **HTTP-only.** Rare — webhook receivers, OAuth callback URLs. Routes - without a meaningful in-process equivalent. May or may not have an - extension. - -### 4. Routes are an `HttpApiGroup`; client uses `AtomHttpApi` - -Plugins ship the same primitive the core uses (`HttpApiGroup` from -`effect/unstable/httpapi`). The host composes via the existing `addGroup` -helper at `packages/core/api/src/api.ts:21`. OpenAPI annotations and docs -flow automatically. - -For the frontend, plugins build a per-plugin `AtomHttpApi.Service` against -their own group, wrapped behind a `createPluginAtomClient(group, opts)` -helper. The resulting atoms are consumed via the existing -`useAtomValue` / `useAtomSet` + `AsyncResult.match` idiom — same pattern -as `toolsAtom`, `sourcesAtom`, etc. - -### 5. SDK re-exports the Effect HttpApi/Schema primitives - -Plugin authors can write a complete plugin importing only from -`@executor-js/sdk` (server) and `@executor-js/sdk/client` (frontend). The SDK -re-exports `Schema`, `HttpApi`, `HttpApiGroup`, `HttpApiEndpoint`, -`HttpApiBuilder`, `Effect` so authors who don't want to dig into Effect -don't have to. Authors who _do_ want Effect-native code keep importing from -`effect` directly. This keeps the door open without forcing the dependency. - -### 6. Skip `capabilities` declaration for v1 - -`["read:secrets", "network:fetch"]` style declarations are useful for sandboxing -but unenforced metadata is just noise. Add when there's a real isolation story. - -### 7. `executor.config.ts` is the single source of truth - -Today the schema-gen CLI reads `executor.config.ts` but the runtime hardcodes -`createLocalPlugins()` in `apps/local/src/server/executor.ts:96`. Consolidate: -the runtime imports from `executor.config.ts` too. One list, two consumers. - -### 8. Canary plugin: build new tiny one first, then migrate openapi - -Validate the shape with a minimal `@executor-js/plugin-example` (one extension -method, one route, one widget) before changing real plugins. - -### 9. Cross-plugin pluggable capabilities: per-capability typed fields - -Some capabilities (secrets, eventually artifacts, maybe more) are -"pluggable" — many plugins can implement them, the host swaps between -providers via config, consumer code stays agnostic. - -Don't generalize this. The existing `secretProviders` field on the spec -already handles this exact pattern for secrets and works fine: - -```ts -// what plugins do today -secretProviders: (ctx) => [makeScopedProvider(...)] -``` - -Each new pluggable capability gets its _own_ typed field on `PluginSpec`, -same shape. When artifacts lands, that's `artifactStore: () => -ArtifactStore`. If connection providers want to be modeled this way, the -existing `connectionProviders` field already is. - -No `provides` / `requires` / `service` machinery, no Effect-Tag-as-generic- -primitive abstraction, no "protocols" concept, no naming debate. The -artifacts note's "protocol" framing translates directly to "the v2 -`artifactStore` field on `PluginSpec`." - -If we eventually have 5–6 of these and the boilerplate genuinely screams -for generalization, we generalize then. Likely won't. - -For v1 nothing changes here — `secretProviders` is what it is, and -artifacts aren't shipping yet. - -## New type sketches - -### `PluginSpec` extension - -Existing fields unchanged. Three new fields: - -```ts -// packages/core/sdk/src/plugin.ts (extension) -import type { Context, Layer } from "effect" -import type { HttpApiGroup } from "effect/unstable/httpapi" - -export interface PluginSpec< - TId, TExtension, TStore, TSchema, - TExtensionService extends Context.Service | undefined = undefined, - THandlersLayer extends Layer.Layer = Layer.Layer, -> { - // ... existing: id, schema, storage, extension, staticSources, - // invokeTool, secretProviders, etc. - - /** npm package name. The Vite plugin emits - * `import "${packageName}/client"` into the frontend bundle, so the - * same `executor.config.ts` drives both server and client. Author - * writes the same string they publish to npm — no transforms, no - * scope conventions, no fallbacks. Required for plugins that ship - * a `./client` entry; omit for SDK-only plugins. */ - readonly packageName?: string - - /** HttpApiGroup contributed by this plugin. Composed into the host's - * HttpApi via the `composePluginApi` helper at runtime. The host - * serves it under whatever base URL the host wires (`/api` for the - * local app's vite middleware) and supplies auth + scope - * middleware. Endpoints automatically appear in the executor OpenAPI - * doc and the typed client. */ - readonly routes?: () => HttpApiGroup.Any - - /** Late-binding handlers Layer for this plugin's group. The layer - * leaves the plugin's extension as a Service Tag requirement (see - * `extensionService` below). The host satisfies it however its - * runtime wants: - * - local: at boot via `Layer.succeed(extensionService)(executor[id])` - * inside `composePluginHandlers(plugins, executor)` - * - cloud: per request via `Effect.provideService(extensionService, - * requestExecutor[id])` inside the auth middleware via - * `providePluginExtensions(plugins)(executor)` */ - readonly handlers?: () => THandlersLayer - - /** Service tag the plugin's `handlers` layer requires. The established - * pattern in each plugin's `*/api/handlers.ts`: `*Handlers` reads - * `*ExtensionService`. The host binds the tag to the live extension — - * at boot for local, per request for cloud. Pairs with `handlers`; - * either both fields are set or neither. */ - readonly extensionService?: TExtensionService -} -``` - -**Why late-binding (`handlers()` not `handlers(self)`).** The original -design had `handlers(self) => Layer` so the host called the factory -with the boot-time extension and got a fully-resolved Layer. That fits -local fine — one executor at boot, one Layer. Cloud builds a fresh -executor per HTTP request (Cloudflare Workers + Hyperdrive needs -fresh DB connections per request), so the executor that satisfies the -extension Service is per-request, not per-boot. The late-binding -shape lets the same plugin spec satisfy both: the `*Handlers` Layer is -built once and consumes `*ExtensionService` from context; local -provides it at boot via `Layer.succeed`, cloud provides it per request -via `Effect.provideService`. - -Example plugin shape — group definition in `shared.ts` so client and -server both import it: - -```ts -// @executor-js/plugin-foo/src/shared.ts -import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk"; - -export const Thing = Schema.Struct({ - id: Schema.String, - name: Schema.String, -}); - -export const FooApi = HttpApiGroup.make("foo") - .add(HttpApiEndpoint.get("listThings")`/things`.addSuccess(Schema.Array(Thing))) - .add( - HttpApiEndpoint.post("syncThing")`/sync/:id` - .setPath(Schema.Struct({ id: Schema.String })) - .addSuccess(Thing) - .addError(SyncError), - ); -``` - -```ts -// @executor-js/plugin-foo/src/server.ts -import { - Context, definePlugin, Effect, HttpApi, HttpApiBuilder, -} from "@executor-js/sdk" -import { FooApi } from "./shared" - -// Bundle the group into a small HttpApi *for typing purposes only*. The -// handlers Layer is keyed by the FooApi group's identity, so it composes -// cleanly into the host's FullApi at runtime regardless of what other -// groups are around it. -const FooApiBundle = HttpApi.make("foo").add(FooApi) - -interface FooExtension { - readonly listThings: () => Effect.Effect - readonly syncThing: (id: string) => Effect.Effect -} - -// Service tag for late-binding — the host satisfies this at boot -// (local) or per request (cloud). Same pattern as the established -// `*ExtensionService` in each plugin's `*/api/handlers.ts`. -export class FooExtensionService extends Context.Service< - FooExtensionService, FooExtension, ->()("FooExtensionService") {} - -const FooHandlers = HttpApiBuilder.group(FooApiBundle, "foo", (h) => - h - .handle("listThings", () => - Effect.gen(function* () { - const ext = yield* FooExtensionService - return yield* ext.listThings() - }), - ) - .handle("syncThing", ({ path }) => - Effect.gen(function* () { - const ext = yield* FooExtensionService - return yield* ext.syncThing(path.id) - }), - ), -) - -export const fooPlugin = definePlugin((opts?: FooConfig) => ({ - id: "foo" as const, - packageName: "@executor-js/plugin-foo", - storage: () => ({ /* ... */ }), - - // Canonical implementation. CLI/tests/embedded callers and the HTTP - // handlers all hit this same code path. - extension: (ctx): FooExtension => ({ - listThings: () => /* Effect — canonical impl */, - syncThing: (id: string) => /* Effect — canonical impl */, - }), - - routes: () => FooApi, - handlers: () => FooHandlers, - extensionService: FooExtensionService, -})) -``` - -Why `routes` and `handlers` are split: `routes` is the API description (a -group), `handlers` is the implementation Layer keyed by group identity. -The host needs the group at composition time (to build `FullApi`) and -the Layer at serve time (to provide handler implementations). -`extensionService` is the third leg: the bridge that lets the host bind -the live extension (at boot or per request) without the plugin author -having to write two handler shapes. - -### `defineClientPlugin` - -Lives in `@executor-js/sdk/client`. Server bundles cannot import this module. - -```ts -// packages/core/sdk/src/client.ts -export interface ClientPluginSpec { - readonly id: TId; - - /** Pages contributed to the host's TanStack router. Mounted under - * /plugins/{id}/{path}. Sidebar nav metadata declared on the route. */ - readonly pages?: readonly PageDecl[]; - - /** Dashboard / overview widgets the host can render in known slots. */ - readonly widgets?: readonly WidgetDecl[]; - - /** Components the host can render in named slots (e.g., source-detail - * panels, secret-picker variants). Slot names are part of the host - * contract — plugin opts in by registering. */ - readonly slots?: Record; - - /** Source-plugin contribution — populated by plugins that expose - * `kind` rows in the core `source` table (openapi, mcp, graphql, - * google-discovery). The host's sources page derives its provider - * list from the union of every loaded plugin's `sourcePlugin` - * via `useSourcePlugins()`. */ - readonly sourcePlugin?: SourcePlugin; - - /** Secret-provider-plugin contribution — populated by plugins that - * also ship a `secretProviders` server-side capability AND want to - * expose a settings card on the host's secrets page. Surfaced via - * `useSecretProviderPlugins()`. */ - readonly secretProviderPlugin?: SecretProviderPlugin; -} - -type PageDecl = { - path: string; // "/", "/edit/$id" - component: ComponentType; - nav?: { label: string; section?: string }; -}; - -type WidgetDecl = { - id: string; - component: ComponentType; - size?: "half" | "full"; -}; - -export const defineClientPlugin = (spec: ClientPluginSpec) => spec; -``` - -### `createPluginAtomClient` — typed reactive client per plugin - -Plugins build their own `AtomHttpApi.Service` against their own group -bundled into a small `HttpApi`. A helper hides the boilerplate so plugin -authors write one line per atom. Same shape as the existing -`ExecutorApiClient` in `packages/react/src/api/client.tsx:11`. - -```ts -// packages/core/sdk/src/client.ts (helper) -import { HttpApi } from "effect/unstable/httpapi"; -import { FetchHttpClient } from "effect/unstable/http"; -import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; - -export const createPluginAtomClient = ( - group: G, - opts: { pluginId: string }, -) => { - const bundle = HttpApi.make(`plugin-${opts.pluginId}`).add(group); - return AtomHttpApi.Service<`Plugin_${string}Client`>()(`Plugin_${opts.pluginId}Client`, { - api: bundle, - httpClient: FetchHttpClient.layer, - baseUrl: `/_executor/plugins/${opts.pluginId}`, - }); -}; -``` - -Plugin author writes: - -```tsx -// @executor-js/plugin-foo/src/client.tsx -import { - defineClientPlugin, - createPluginAtomClient, - useAtomValue, - useAtomSet, - AsyncResult, -} from "@executor-js/sdk/client"; -import { FooApi } from "./shared"; - -const FooClient = createPluginAtomClient(FooApi, { pluginId: "foo" }); - -export const fooThingsAtom = FooClient.query("foo", "listThings", { - timeToLive: "30 seconds", - reactivityKeys: ["foo:things"], -}); - -export const fooSync = FooClient.mutation("foo", "syncThing"); - -const FooPage = () => { - const things = useAtomValue(fooThingsAtom); - const doSync = useAtomSet(fooSync, { mode: "promise" }); - - return AsyncResult.match(things, { - onInitial: () => , - onFailure: () =>

Failed to load

, - onSuccess: ({ value }) => doSync({ path: { id } })} />, - }); -}; - -export default defineClientPlugin({ - id: "foo" as const, - pages: [{ path: "/", component: FooPage, nav: { label: "Foo" } }], - widgets: [{ id: "foo-status", component: FooStatus, size: "half" }], -}); -``` - -Type inference: `FooClient.query("foo", "listThings", ...)` is fully typed -against `FooApi` — same checks the existing `ExecutorApiClient.query("tools", -"list", ...)` performs. No codegen, no host-wide composed-API typing -required. Each plugin is self-contained in its client typing. - -Server-side composition: the host calls four helpers in -`@executor-js/api` (also re-exported from `@executor-js/api/server`). -Each plugin's handlers Layer is keyed by its group's identity, so it -slots into the composed API without the host needing the typed structure. - -```ts -import { - composePluginApi, // routes() → composed HttpApi - composePluginHandlers, // handlers() + extensionService bound eagerly - composePluginHandlerLayer, // handlers() merged, Tag still unbound - providePluginExtensions, // per-request Tag binding -} from "@executor-js/api/server" - -// Local — single boot-time executor; satisfy Tags eagerly. -const FullApi = composePluginApi(plugins) -const SpecHandlers = composePluginHandlers(plugins, executor) // self bound -const ServerLive = HttpApiBuilder.layer(FullApi).pipe( - Layer.provide(CoreHandlers), - Layer.provide(SpecHandlers), - // ... -) - -// Cloud — per-request executor; satisfy Tags inside an HttpRouter middleware. -const FullApi = composePluginApi(plugins) -const SpecHandlers = composePluginHandlerLayer(plugins) // Tag still unbound -const provideExt = providePluginExtensions(plugins) - -const ExecutionStackMiddleware = HttpRouter.middleware<{ - provides: AuthContext | ExecutorService | ExecutionEngineService - | , -}>()( - Effect.gen(function* () { - return (httpEffect) => - Effect.gen(function* () { - const executor = yield* makeExecutionStack(...) - return yield* httpEffect.pipe( - Effect.provideService(ExecutorService, executor), - provideExt(executor), // binds each plugin's Tag per request - ) - }) - }), -) -``` - -Effect errors flow through the existing typed-error machinery — same as -core handlers in `packages/core/api/src/handlers/`. - -### Loader: `executor.config.ts` → runtime + frontend bundle - -Single config, two consumers. - -**Backend** (replaces `createLocalPlugins`): - -```ts -// apps/local/src/server/executor.ts (after) -import config from "../../executor.config.ts"; - -const executor = - yield * - createExecutor({ - scopes: [scope], - adapter, - blobs, - plugins: config.plugins, - onElicitation: "accept-all", - }); -``` - -The plugins are already configured (factory called) by the time -`executor.config.ts` is evaluated, so no async loader needed. - -**Frontend** — Vite plugin reads the same config, resolves each plugin's -`./client` export, exposes a virtual module: - -```ts -// packages/vite-plugin-executor/src/index.ts (pseudocode) -export default function executorVite(): Plugin { - return { - name: "executor-plugins", - resolveId(id) { - if (id === "virtual:executor/plugins-client") return "\0" + id; - }, - async load(id) { - if (id !== "\0virtual:executor/plugins-client") return; - const config = await loadExecutorConfig(); - const imports = config.plugins.map((p, i) => `import p${i} from "${p.id}/client"`).join("\n"); - const list = config.plugins.map((_, i) => `p${i}`).join(", "); - return `${imports}\nexport const plugins = [${list}]`; - }, - }; -} -``` - -Host app consumes via `` at the root, then -focused hooks (`useSourcePlugins` / `useSecretProviderPlugins` / -`useClientPlugins`) inside pages and shared components. The host's -routes don't import the virtual module or any per-app aggregator -file — `__root.tsx` is the only place that touches -`virtual:executor/plugins-client`. - -```tsx -// apps/local/src/routes/__root.tsx -import { createRootRoute } from "@tanstack/react-router"; -import { ExecutorProvider } from "@executor-js/react/api/provider"; -import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { plugins as clientPlugins } from "virtual:executor/plugins-client"; -import { Shell } from "../web/shell"; - -export const Route = createRootRoute({ - component: () => ( - - - - - - ), -}); -``` - -```tsx -// e.g. inside packages/react/src/pages/sources.tsx -import { useSourcePlugins } from "@executor-js/sdk/client"; - -export function SourcesPage() { - const sourcePlugins = useSourcePlugins(); - // ... -} -``` - -The catch-all `/plugins/$pluginId/$` route uses `useClientPlugins()` -to look up which plugin's pages to mount. - -HMR works because the virtual module is part of Vite's graph. Adding a plugin -needs a Vite restart (not a hot update — config changed). - -## End-to-end example plugin - -``` -@executor-js/plugin-example/ -├── package.json -└── src/ - ├── server.ts - ├── client.tsx - └── shared.ts -``` - -```ts -// src/shared.ts — only @executor-js/sdk imports, no raw effect imports -import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk"; - -export const Greeting = Schema.Struct({ - message: Schema.String, - count: Schema.Number, -}); -export type Greeting = typeof Greeting.Type; - -export const ExampleApi = HttpApiGroup.make("example").add( - HttpApiEndpoint.post("greet", "/greet", { - payload: Schema.Struct({ name: Schema.String }), - success: Greeting, - }), -); -``` - -```ts -// src/server.ts -import { Context, definePlugin, Effect, HttpApi, HttpApiBuilder } from "@executor-js/sdk"; -import { ExampleApi } from "./shared"; - -const ExampleApiBundle = HttpApi.make("example").add(ExampleApi); - -interface ExampleExtension { - readonly greet: ( - name: string, - ) => Effect.Effect<{ readonly message: string; readonly count: number }>; -} - -export class ExampleExtensionService extends Context.Service< - ExampleExtensionService, - ExampleExtension ->()("ExampleExtensionService") {} - -const ExampleHandlers = HttpApiBuilder.group(ExampleApiBundle, "example", (h) => - h.handle("greet", ({ payload }) => - Effect.gen(function* () { - const ext = yield* ExampleExtensionService; - return yield* ext.greet(payload.name); - }), - ), -); - -export const examplePlugin = definePlugin(() => ({ - id: "example" as const, - packageName: "@executor-js/plugin-example", - storage: () => ({ count: 0 }), - - // Canonical implementation lives here. - extension: (ctx): ExampleExtension => ({ - greet: (name: string) => - Effect.sync(() => { - ctx.storage.count += 1; - return { - message: `hello ${name}`, - count: ctx.storage.count, - }; - }), - }), - - routes: () => ExampleApi, - handlers: () => ExampleHandlers, - extensionService: ExampleExtensionService, -})); - -export default examplePlugin; -``` - -```tsx -// src/client.tsx -import { defineClientPlugin, createPluginAtomClient, useAtomSet } from "@executor-js/sdk/client"; -import { useState } from "react"; -import { ExampleApi } from "./shared"; - -const ExampleClient = createPluginAtomClient(ExampleApi, { pluginId: "example" }); - -const greetAtom = ExampleClient.mutation("example", "greet"); - -const ExamplePage = () => { - const [name, setName] = useState("world"); - const [result, setResult] = useState(); - const doGreet = useAtomSet(greetAtom, { mode: "promise" }); - - return ( -
- setName(e.target.value)} /> - - {result &&
{result}
} -
- ); -}; - -export default defineClientPlugin({ - id: "example" as const, - pages: [{ path: "/", component: ExamplePage, nav: { label: "Example" } }], -}); -``` - -```jsonc -// package.json -{ - "name": "@executor-js/plugin-example", - "type": "module", - "exports": { - "./server": "./dist/server.js", - "./client": "./dist/client.js", - }, - "executor": { "id": "example", "version": "0.1.0" }, - "peerDependencies": { - "@executor-js/sdk": "workspace:*", - "react": "catalog:", - }, -} -``` - -User adds it: - -```ts -// apps/local/executor.config.ts -import { examplePlugin } from "@executor-js/plugin-example/server"; -plugins: [, /* ... */ examplePlugin()]; -``` - -## SDK-only plugin example - -Many plugins don't need HTTP at all — utilities, providers, anything used -purely from other plugins or scripts. The plugin shape collapses to one -file with no `./client` export, no `routes`, no `handlers`. - -``` -@executor-js/plugin-rate-limiter/ -├── package.json -└── src/ - └── server.ts -``` - -```jsonc -// package.json -{ - "name": "@executor-js/plugin-rate-limiter", - "type": "module", - "exports": { "./server": "./dist/server.js" }, - "executor": { "id": "rateLimiter", "version": "0.1.0" }, - "peerDependencies": { "@executor-js/sdk": "workspace:*" }, -} -``` - -```ts -// src/server.ts -import { definePlugin, defineSchema, Effect, Schema } from "@executor-js/sdk"; - -interface RateLimiterConfig { - defaultLimit?: number; -} - -class RateLimitExceeded extends Schema.TaggedError()("RateLimitExceeded", { - key: Schema.String, - retryAfterMs: Schema.Number, -}) {} - -export const rateLimiterPlugin = definePlugin((opts: RateLimiterConfig = {}) => ({ - id: "rateLimiter" as const, - - schema: defineSchema({ - rate_buckets: { - fields: { - key: { type: "string", primary: true }, - tokens: { type: "number" }, - updated_at: { type: "number" }, - }, - }, - }), - - storage: ({ adapter }) => ({ - adapter, - defaultLimit: opts.defaultLimit ?? 60, - }), - - extension: (ctx) => ({ - check: (key: string, cost = 1) => - Effect.gen(function* () { - const bucket = yield* readOrCreateBucket(ctx.storage, key); - const refilled = refill(bucket, ctx.storage.defaultLimit); - if (refilled.tokens < cost) { - return yield* new RateLimitExceeded({ - key, - retryAfterMs: estimateRetry(refilled), - }); - } - yield* writeBucket(ctx.storage, key, refilled.tokens - cost); - return { allowed: true, remaining: refilled.tokens - cost }; - }), - - reset: (key: string) => - Effect.tryPromise(() => - ctx.storage.adapter.delete({ - model: "rate_buckets", - where: [["key", "=", key]], - }), - ), - }), -})); -``` - -Pure programmatic consumption — works identically from CLI, tests, -embedded library use, or another plugin's `extension`: - -```ts -const result = yield * executor.rateLimiter.check("user-123", 5); -// ^? { allowed: true; remaining: number } - -yield * - executor.rateLimiter - .check("user-123", 1000) - .pipe( - Effect.catchTag("RateLimitExceeded", (err) => - Effect.log(`hit limit on ${err.key}, retry in ${err.retryAfterMs}ms`), - ), - ); -``` - -What the host does with this plugin: - -- **Backend** composes it into `createExecutor`, exposes `executor.rateLimiter.*`. ✅ -- **HTTP server** sees no `routes`/`handlers` — mounts nothing. ✅ -- **Vite plugin** sees no `./client` export in `package.json`'s exports map — adds nothing to the frontend bundle. ✅ -- **CLI** uses `executor.rateLimiter.*` directly. ✅ - -The plugin is invisible to anything not calling it — no HTTP surface, no -frontend bundle cost, no auth surface to review. - -## Sequencing - -Build order — all shipped. - -1. ✅ **Consolidate config.** `apps/local/src/server/executor.ts` reads - from `executor.config.ts`. `createLocalPlugins` deleted. The cloud - app's equivalent (`createOrgPlugins` in `apps/cloud/src/services/executor.ts`) - also gone — driven from `apps/cloud/executor.config.ts` via a - `(deps) => plugins` factory shape. -2. ✅ **SDK re-exports.** `Context`, `Effect`, `Layer`, `Schema`, - `Data`, `Option` plus `HttpApi*` re-exported from `@executor-js/sdk`. - `@executor-js/sdk/client` mirrors with `Schema`, `HttpApi*`, - `AsyncResult`, `Atom`, `AtomHttpApi`, and React hooks. -3. ✅ **`routes` + `handlers` + `extensionService` on `PluginSpec`.** - Host-side composition lives in - `packages/core/api/src/plugin-routes.ts`: `composePluginApi`, - `composePluginHandlers`, `composePluginHandlerLayer`, - `providePluginExtensions`. The local app uses the eager pair - (`composePluginHandlers`); cloud uses the late-binding pair - (`composePluginHandlerLayer` + `providePluginExtensions` per request). -4. ✅ **`defineClientPlugin` + `createPluginAtomClient` + Vite plugin.** - `@executor-js/sdk/client` exports the factory and helper. - `@executor-js/vite-plugin` resolves each plugin's `./client` from - `executor.config.ts` and exposes `virtual:executor/plugins-client`. -5. ✅ **`@executor-js/plugin-example`.** End-to-end proof: shared - group, server with extension/routes/handlers/extensionService, - client with `defineClientPlugin` + reactive page. -6. ✅ **Migrate every plugin.** `openapi`, `mcp`, `google-discovery`, - `onepassword`, `graphql`, `workos-vault`, `keychain`, `file-secrets` - all driven by spec. Each that ships UI also has a `./client` - `defineClientPlugin` exposing its `sourcePlugin` / - `secretProviderPlugin`. -7. ✅ **`` + hooks.** Host wraps once at - `__root.tsx`; pages call `useSourcePlugins()` / - `useSecretProviderPlugins()` / `useClientPlugins()`. Per-route - imports of plugin lists are gone. - -## Open questions / deferred - -- **Pluggable artifacts.** When workflows lands, add an `artifactStore` - field on `PluginSpec` shaped like `secretProviders` — typed, no generic - abstraction. The artifacts note's "protocol package + provider plugin + - feature plugin" translates to "shared interface package + provider - plugin's `artifactStore` field + consumer plugin reading from `ctx`." -- **Codegen-based string specs.** If we ever want config from a database or - remote URL, switch to strings + a TanStack-Router-style generated `.d.ts`. - Not needed until multi-tenant. -- **Electron dynamism.** Currently desktop bakes plugins at build time. To let - desktop users install plugins post-ship, would need either a runtime ESM - story (import maps) or a "plugin pack" prebuilt-bundle model. Out of scope - for v1. -- **Sandboxing.** Plugins run in-process with full host trust. Worth revisiting - when we have untrusted plugins (marketplace) — likely via dynamic-software's - Worker Loader pattern or similar. -- **Per-version plugin isolation.** Cloud-only concern. -- **Hot reload of plugin code.** Restart is fine for v1; would be nice but - costs significant design effort. - -## References - -**Reference research (pre-design):** - -- emdash: `astro.config.mjs` import-and-call pattern, `package.json` exports - for separate admin entry — see `.reference/emdash/demos/plugins-demo/`. -- dynamic-software: PluginManifest + capability declarations idea (deferred); - Worker Loader for cloud isolation — see `.reference/dynamic-software/`. -- openclaw: manifest-first control plane idea (deferred but worth revisiting - when we add capability enforcement). - -**Shipped code:** - -- Plugin contract: `packages/core/sdk/src/plugin.ts` (`PluginSpec`, - `definePlugin`). -- Client contract: `packages/core/sdk/src/client.ts` - (`defineClientPlugin`, `ExecutorPluginsProvider`, - `useSourcePlugins`, `useSecretProviderPlugins`, `useClientPlugins`, - `createPluginAtomClient`). -- Host composition helpers: `packages/core/api/src/plugin-routes.ts` - (`composePluginApi`, `composePluginHandlers`, - `composePluginHandlerLayer`, `providePluginExtensions`). -- Vite plugin: `packages/core/vite-plugin/src/index.ts`. -- Canary plugin: `packages/plugins/example/`. -- Local config + wiring: `apps/local/executor.config.ts`, - `apps/local/src/server/main.ts`, `apps/local/src/routes/__root.tsx`. -- Cloud config + wiring: `apps/cloud/executor.config.ts`, - `apps/cloud/src/api/protected-layers.ts`, - `apps/cloud/src/api/protected.ts`, - `apps/cloud/src/routes/__root.tsx`. -- Pluggable-capability precedent: existing `secretProviders` field on - `PluginSpec`. When artifacts ship, the new `artifactStore` field - follows the same per-capability-typed-field pattern. - -**Adjacent plans:** - -- `notes/artifacts-workflows-and-generated-ui.md` — the artifacts/workflows - plan. Uses "protocol" terminology that we're not adopting; read its - "ArtifactStoreProtocol" as "the typed interface that goes into the - `artifactStore` field." See decision #9. -- Earlier plugin first-principles thinking: - `personal-notes/plugin-system-first-principles.md`, - `personal-notes/plugin-system-primitive-and-use-cases.md`. diff --git a/notes/livestore-effect-testing-porting.md b/notes/livestore-effect-testing-porting.md deleted file mode 100644 index bdb625d35..000000000 --- a/notes/livestore-effect-testing-porting.md +++ /dev/null @@ -1,196 +0,0 @@ -# LiveStore Effect Testing Patterns Worth Porting - -LiveStore is useful as a reference repo for Effect-heavy test ergonomics, not -as a wholesale tooling model. Executor already has the stricter Effect lint -posture and a simpler Bun/Turbo toolchain. The part worth copying is the small -test harness shape that makes scoped Effect tests easier to write and easier to -debug when they hang. - -Reference checkout: - -```txt -.reference/livestore -``` - -## What to Port - -### Effect test context helper - -LiveStore's `packages/@livestore/utils-dev/src/node-vitest/Vitest.ts` wraps -`@effect/vitest` with: - -- `withTestCtx(test)` for per-test Effect setup. -- `makeWithTestCtx(...)` for reusable layer/timeout config. -- A default timeout, longer in CI. -- `Effect.logWarnIfTakesLongerThan` before the timeout trips. -- `Effect.timeout(...)`. -- `Effect.provide(...)` for per-test layers. -- `Effect.scoped` so finalizers and spans close predictably. -- Optional tracing/log layer wiring. - -Executor should port the shape, not the exact implementation. LiveStore is on -Effect 3, while Executor is on Effect 4 beta. The local helper should be tiny -and typed against the current `@effect/vitest` and `effect` APIs. - -Likely home: - -```txt -packages/core/sdk/src/testing.ts -``` - -If the helper starts pulling in cloud-specific, Node-server, or plugin-specific -dependencies, stop and keep those helpers package-local instead. - -### Timeout diagnostics for slow Effect tests - -Several Executor tests already have manual sleeps, `Promise.race`, test-level -timeouts, or long-running fixtures. A `withTestCtx`-style helper would make -failures more readable by logging before timeout rather than only surfacing a -late Vitest timeout. - -Good initial targets: - -- `apps/cloud/src/mcp-miniflare.e2e.node.test.ts` -- `apps/cloud/src/mcp-session.e2e.node.test.ts` -- `packages/plugins/openapi/src/sdk/oauth-refresh.test.ts` -- `packages/plugins/mcp/src/sdk/connection-pool.test.ts` -- `packages/core/execution/src/tool-invoker.test.ts` - -The first slice should convert only one painful test cluster. If it makes the -test body clearer and failure output better, then expand. - -### Scoped fixture/runtime pattern - -LiveStore's `tests/sync-provider/src/sync-provider.test.ts` builds a shared -`ManagedRuntime` in `beforeAll`, disposes it in `afterAll`, then creates -per-test providers with isolated ids. That pattern is a good fit for expensive -Executor integration fixtures: - -- local HTTP protocol servers, -- MCP servers, -- OAuth mock servers, -- Miniflare environments, -- database-backed cloud services, -- plugin SDK tests that need realistic server behavior. - -Executor already uses `layer(TestLayer)(...)` in some OpenAPI and cloud tests. -Keep that where it works. Use the LiveStore runtime pattern only where the -fixture is expensive enough that rebuilding it per test is wasteful or flaky. - -### Property-test wrapper later - -LiveStore's `asProp` wrapper normalizes FastCheck options and makes shrinking -progress clearer. Do not port it preemptively. - -It becomes useful if Executor adds property tests for: - -- OpenAPI parameter encoding, -- form/multipart request bodies, -- schema round-trips, -- scope ordering and shadowing, -- tool/source dedupe, -- storage adapter conformance. - -Until then, direct `@effect/vitest` property tests are enough. - -### Possibly scoped React perf lint - -LiveStore enables `react-perf` oxlint rules for JSX props: - -- no new functions as props, -- no new objects as props, -- no JSX as props, -- no new arrays as props. - -This might be useful in `packages/react`, but it should start as a scoped -experiment. Do not enable it repo-wide. The risk is turning useful UI work into -memoization churn. - -## What Not to Port - -### Biome - -LiveStore uses Biome for formatting/import organization plus oxlint for linting. -Executor already uses `oxfmt` and oxlint: - -```txt -bun run format -bun run format:check -bun run lint -``` - -Adding Biome would split formatter ownership without solving an Executor -problem. - -### devenv / genie / effect-utils repo generation - -LiveStore's config generation and `devenv` task setup are substantial -infrastructure. Executor's Bun/Turbo setup is smaller and easier to reason -about. Do not port that unless there is a concrete repo-management problem that -cannot be solved locally. - -### LiveStore's lint rules wholesale - -Executor's lint posture is already stronger and more domain-specific. Executor -currently bans or guides: - -- raw Vitest imports, -- conditional tests, -- double casts, -- cross-package relative imports, -- missing effect-atom reactivity keys, -- Effect escape hatches, -- unsupported Effect APIs, -- `new Error`, -- `try`/`catch` and raw `throw`, -- `Promise.catch` / `Promise.reject`, -- raw fetch outside approved boundaries, -- manual tagged-error checks, -- duplicated schema/value-derived types. - -LiveStore disables several things Executor intentionally cares about, including -some broad TypeScript strictness. Their rule set should stay reference-only. - -### An Effect barrel module - -LiveStore centralizes Effect exports through `@livestore/utils/effect`. -Executor mostly imports from `effect` directly. Do not introduce a broad -`@executor-js/.../effect` barrel just for symmetry. It would add another import -surface without a concrete need. - -## First Implementation Slice - -Add a tiny test helper, then use it in one test cluster. - -Possible local API: - -```ts -export const withTestCtx = - (test: TestContext, options?: TestContextOptions) => - (effect: Effect.Effect) => - effect.pipe( - Effect.logWarnIfTakesLongerThan({ - duration: "...", - label: "...", - }), - Effect.timeout("..."), - Effect.provide(options?.layer ?? Layer.empty), - Effect.scoped, - ); -``` - -Keep the helper small: - -- no tracing dependency in the first pass, -- no generic framework package, -- no large fixture registry, -- no migration of every test. - -Validation should be narrow: - -```txt -vitest run --testNamePattern "" -``` - -If the first conversion produces clearer test bodies and better failure output, -then expand it to the other long-running Effect integration tests. diff --git a/notes/mcp-apps-executor-gateway.md b/notes/mcp-apps-executor-gateway.md deleted file mode 100644 index 5994dec9e..000000000 --- a/notes/mcp-apps-executor-gateway.md +++ /dev/null @@ -1,227 +0,0 @@ -# MCP Apps Through Executor Gateway - -Executor can already act as a gateway to downstream MCP servers for tools. MCP -Apps support should preserve that same gateway property: add Executor once, and -UI-capable tools from downstream MCPs should keep working through the Executor -MCP server. - -This note is about the Executor work, not the GitHub issue demo app. - -## Goals - -- Proxy downstream MCP Apps without degrading the direct MCP server experience. -- Make UI-capable tools discoverable through Executor's normal MCP surface. -- Support app template/resource loading through `resources/list`, - `resources/templates/list`, and `resources/read`. -- Keep tool invocation and resource loading consistent across direct MCP, - Executor gateway MCP, and future generated UI. -- Leave room for generated React UIs to embed existing MCP Apps as components. - -## Terms - -MCP Apps are not a separate top-level MCP object. In practice they are -UI-capable tools: a tool descriptor includes metadata pointing to a UI resource, -and the client reads that resource to render the app. - -There are two compatibility tracks to care about: - -- MCP Apps / ext-apps metadata: `_meta.ui.resourceUri`, often flattened as - `_meta["ui/resourceUri"]`, with a `ui://...` resource whose MIME type is - commonly `text/html;profile=mcp-app`. -- ChatGPT Apps SDK / Skybridge metadata: `_meta["openai/outputTemplate"]`, - usually pointing to a `ui://widget/...` HTML resource with - `text/html+skybridge`. - -Executor should preserve both when present. It should not assume all hosts have -converged on one metadata key or MIME type yet. - -## Resource Gateway - -Executor needs a generic resource surface in addition to the current tool -surface. - -Suggested executor-side APIs: - -```ts -executor.resources.list({ source?: string, cursor?: string }) -executor.resources.templates.list({ source?: string, cursor?: string }) -executor.resources.read({ uri: string }) -``` - -The MCP plugin should implement these by forwarding to downstream MCP -`resources/list`, `resources/templates/list`, and `resources/read`. - -The host MCP server should expose the same through protocol handlers: - -```txt -resources/list -resources/templates/list -resources/read -notifications/resources/list_changed -notifications/resources/updated -``` - -Resource reads must not go through tool calls. The app renderer expects a -resource read path that can fetch the template URI returned in tool metadata. - -## URI Proxying - -Executor can prefix downstream resource URIs and strip that prefix when it -forwards a request to the source MCP server. - -Example: - -```txt -downstream: ui://app/show_github_issue.html -executor: executor+mcp://{sourceId}/ui/app/show_github_issue.html -``` - -or: - -```txt -downstream: ui://app/show_github_issue.html -executor: ui://executor/{sourceId}/app/show_github_issue.html -``` - -The first form is less likely to collide with app code that has assumptions -about the `ui://` authority/path shape. The second form may be friendlier to -hosts that only recognize `ui://`. - -The important invariant is: - -- every resource URI exposed by Executor must be globally unique inside the - gateway MCP server -- Executor must maintain a reversible mapping back to `{ sourceId, uri }` -- tool metadata, tool results, resource listings, and resource contents should - all use the same rewritten URI - -## Response Body Rewriting - -URI rewriting in metadata is not enough. HTML and JavaScript inside app -templates may contain baked-in references to their own `ui://...` resources or -may call host APIs with those URIs. - -Executor probably needs a conservative response-rewrite layer for proxied app -resources: - -- rewrite exact downstream resource URIs inside text resources -- rewrite known metadata keys in embedded JSON -- avoid broad string replacement that can corrupt unrelated content -- do not rewrite binary resources unless we add a format-specific rewriter - -This is especially relevant for app HTML and JS. A downstream app might call -`window.openai.readResource("ui://...")`, use an MCP Apps host bridge resource -read method, or import a sibling resource by URI. If Executor has exposed the -app under a prefixed URI, those calls must use the prefixed form at the host -boundary and the stripped form at the downstream boundary. - -## Iframe Shape - -Gateway mode should not introduce a double app iframe. - -The expected shape is: - -```txt -ChatGPT / host - iframe for downstream app template served through Executor -``` - -Executor should own the MCP transport/resource proxying, not wrap every -downstream app in an Executor app shell. A shell iframe is appropriate for -Executor's own generated UI feature, but direct downstream MCP Apps should render -as if the client connected to the downstream MCP server directly. - -## Host Bridge Interception - -The `client` or `window.openai` object used by the app belongs to the host/app -iframe runtime, not the agent model. Executor can influence it only through: - -- the tool descriptor metadata it exposes -- the resource body it serves -- the resource URI mapping it applies -- any app shell it intentionally provides for Executor-owned generated UI - -For proxied downstream apps, avoid injecting a custom client unless required for -compatibility. Injection creates a new compatibility surface and risks changing -how existing apps behave. - -For Executor-owned generated UI, a shell can provide controlled APIs: - -```ts -tools.github.issues.create(...) -tools.run(...) -UI.SomeDownstreamApp(...) -``` - -That is a separate feature from transparent gatewaying. - -## Generated UI Integration - -The open generative UI PR adds an Executor app shell that can render React -generated by the model and proxy tool calls back through Executor. That shell is -the right place to experiment with treating UI-capable tools as components. - -Possible future convention: - -```tsx - -``` - -The generated UI shell would: - -- resolve `UI.*` to a UI-capable tool -- call the tool or reuse supplied tool output -- load the tool's app template resource -- mount the downstream MCP App within a component boundary - -This should be built on top of the same resource gateway. Generated UI should -not need a bespoke resource loader that bypasses Executor's MCP resource proxy. - -## Compatibility Notes From Spike - -- ChatGPT normalized a tool call to `show_github_issue`; an xmcp tool registered - as `show-github-issue` failed with `Tool show_github_issue not found`. - Gateway code should avoid changing tool names after discovery, and demo/tools - intended for ChatGPT should prefer identifier-safe names. -- ChatGPT developer mode showed the widget template metadata separately from - tool response metadata. Both descriptor metadata and tool-result metadata - matter. -- Adding `_meta["openai/outputTemplate"]` alongside `_meta["ui/resourceUri"]` - improved host compatibility. -- A host may say "Failed to fetch template" even when `tools/call` succeeds. - Debug that as a resource-read/template metadata problem, not as a tool - invocation problem. - -## Open Questions - -- Which URI prefix shape should Executor standardize on for proxied resources? -- Should Executor expose both original and rewritten URI in hidden metadata for - debugging? -- Do we need resource body rewriting in v1, or can v1 document that apps must - use host-provided current template state rather than hard-coded sibling URIs? -- How do we handle resource subscriptions through a gateway? -- Should `tool.search` include "has UI" / "template URI" hints so agents can - intentionally choose UI-capable tools? -- Should generated UI expose MCP Apps as components directly, or only via a - generic `` primitive at first? - -## References - -- MCP resources specification: - https://modelcontextprotocol.io/specification/draft/server/resources -- MCP schema reference for `resources/list` and `resources/read`: - https://modelcontextprotocol.io/specification/2025-06-18/schema -- MCP Apps `RESOURCE_URI_META_KEY` docs: - https://apps.extensions.modelcontextprotocol.io/api/variables/app.RESOURCE_URI_META_KEY.html -- xmcp resource docs: - https://xmcp.dev/docs/core-concepts/resources -- xmcp MCP UI integration docs: - https://xmcp.dev/docs/integrations/mcp-ui -- xmcp MCP Apps metadata docs: - https://xmcp.dev/docs/core-concepts/tools#mcp-apps-metadata -- OpenAI Apps SDK / Skybridge compatibility docs: - https://docs.skybridge.tech/fundamentals/apps-sdk -- Skybridge `registerWidget` API: - https://docs.skybridge.tech/api-reference/register-widget -- Executor generative UI PR: - https://github.com/RhysSullivan/executor/pull/263 diff --git a/notes/mcp-conn-pool.md b/notes/mcp-conn-pool.md deleted file mode 100644 index 51f1fc3bc..000000000 --- a/notes/mcp-conn-pool.md +++ /dev/null @@ -1,137 +0,0 @@ -# MCP connection pool — investigation notes (2026-04-30) - -## TL;DR - -The MCP plugin **already pools connections within a session DO**. Production -telemetry attributing 17% of `plugin.mcp.connection.acquire` calls to a -fresh `plugin.mcp.connection.handshake` represents _cold-start sessions_, -not redundant in-session handshakes. No structural change is required; -this PR ships a strict regression test that pins the existing contract -plus a `plugin.mcp.cache_hit` span attribute so future telemetry can -distinguish hits from misses without cross-referencing handshake counts. - -## What's already there - -`packages/plugins/mcp/src/sdk/plugin.ts#makeRuntime`: - -```ts -const connectionCache = yield* ScopedCache.make({ - lookup: (key: string) => - Effect.acquireRelease( - Effect.suspend(() => { - const connector = pendingConnectors.get(key); - ... - }), - (connection) => Effect.promise(() => connection.close().catch(() => {})), - ), - capacity: 64, - timeToLive: Duration.minutes(5), -}).pipe(Scope.extend(cacheScope)); -``` - -Properties: - -- One `ScopedCache` per plugin instance, captured by the closure - `runtimeRef`. Lifecycle is the executor (one executor per session DO, - built in `apps/cloud/src/services/executor.ts#createScopedExecutor`). -- LRU with `capacity: 64`, `timeToLive: 5 min`. -- Lookup runs inside a Scope held by `cacheScope`, so the pooled - connection survives `Effect.scoped` boundaries inside `invokeMcpTool`. -- Cache key (`invoke.ts#connectionCacheKey`) is - `remote:${invokerScope}:${endpoint}` for remote sources and - `stdio:${command}` for stdio. Includes `invokerScope` so per-user - OAuth/header secrets never collapse onto a shared connection. -- `invokeMcpTool` retries once on connection error: invalidates the - cache entry and re-acquires. Stale connections are caught here and - transparently re-handshaked. - -## Why the pool is plausibly correct - -`packages/plugins/mcp/src/sdk/elicitation.test.ts` already had a test -`"connection is reused across multiple tool calls to the same source"` -that asserts the underlying MCP server sees no new HTTP sessions across -three sequential `executor.tools.invoke` calls — passes against current -code. - -This PR adds two stricter regression cases under -`packages/plugins/mcp/src/sdk/connection-pool.test.ts`: - -1. Five sequential invokes of the same tool — one handshake total. -2. Different tools on the same source — still one handshake, different - tool ids hit the same cache key. - -Both pass without any source change. They serve as a deterministic -contract pin so a future refactor that breaks pooling (e.g., scoping -the cache to the per-invoke scope, dropping `Scope.extend(cacheScope)`, -mutating the cache key per call) trips immediately in CI. - -## Re-reading the production trace - -Trace `b7102047bed975da461c0519d1251de4`: - -- `mcp.plugin.resolve_connector` 2.72s -- `plugin.mcp.connection.acquire` 2.72s -- `plugin.mcp.connection.handshake` 2.52s -- `executor.storage.transaction` 1.02s (token persist) -- `plugin.mcp.client.call_tool` 1.48s - -Span nesting plus `resolve_connector ≈ acquire ≈ handshake` durations is -the cache-miss path: `resolveConnector` only runs (and emits its span) -when the cache lookup actually invokes the lookup closure, which only -happens on a miss. - -8h aggregate: - -- `plugin.mcp.connection.handshake` count 4 -- `plugin.mcp.connection.acquire` count 24 -- `mcp.plugin.resolve_connector` count 4 - -Acquire count is 6× handshake count. The cache _is_ hitting on 20 of 24 -calls. The 4 misses are cold-start sessions (each MCP session DO's first -tool call, plus any session where the connection went stale and was -invalidated by the retry path). Six tool calls per session is consistent -with normal MCP usage. - -## Why we can't drive misses below the cold-start floor - -Every miss observed in prod is structurally unavoidable inside a single -DO: - -- **First tool invocation in a fresh DO.** `init()` builds the executor - and a fresh `ScopedCache`. The first invoke must handshake. -- **Stale-connection retry.** `invokeMcpTool` invalidates the cache - entry on `client.callTool` failure and re-acquires; that re-acquire - is necessarily a miss. -- **TTL expiry.** Set to 5 minutes. The session DO's idle alarm fires - at 5 minutes too, so any cache entry that out-survives a session was - going to be discarded with the DO anyway. - -To go below this floor we'd need either: - -1. **Pre-warm in `init()`.** Plausible but speculative — we don't know - which sources the user will invoke, and warming all of them - bottlenecks `init()` on the slowest server. Out of scope; would also - reverse the savings if the user never calls those tools. -2. **Cross-DO connection pool.** Forbidden by the task — and the - per-DO scope of `runtimeRef` is the right home for a per-user-org - connection pool given the cache key includes `invokerScope`. - -## What this PR ships - -1. `packages/plugins/mcp/src/sdk/connection-pool.test.ts` — two - strict regression tests pinning the per-session pooling contract. -2. `packages/plugins/mcp/src/sdk/invoke.ts` — adds - `plugin.mcp.cache_hit: boolean` attribute to the - `plugin.mcp.connection.acquire` span. Future Axiom queries can read - the hit rate directly without comparing acquire vs handshake counts. - -No behavior change. - -## Follow-ups - -- If the cold-start p99 still bites tail latency, consider warming the - most-recently-used source(s) for a session DO during `init()` — - scoped to the user's recent activity, e.g., on session restore. -- Watch `plugin.mcp.cache_hit=false` rate over a longer window. If it - exceeds the implied cold-start floor (≈ 1 / avg-calls-per-session), - there's a real bug to chase. diff --git a/notes/old/auth.md b/notes/old/auth.md deleted file mode 100644 index 4a26da245..000000000 --- a/notes/old/auth.md +++ /dev/null @@ -1,92 +0,0 @@ -https://x.com/thdxr/status/1788284129810772367 -https://x.com/thdxr/status/1651434348174778370 - -i am so tired of SaaS companies getting auth wrong - it's the first thing your users experience please get it right - -i should be able to use a single email and attach multiple workspaces - -i should be able to stay logged in with multiple emails and switch between workspaces -12:05 PM · May 8, 2024 -· -233.1K -Views -Relevant -View quotes - -dax - -@thdxr -· -May 8, 2024 -@StainlessAPI -you force github login...then ask me to associate my work email withit - -i have a single personal github as do most people - if i'm logged into it it's going to be my personal email - -i have no way to now use your product across multiple projects -dax - -@thdxr -· -May 8, 2024 -@tursodatabase -is using -@clerk -so not sure who's at fault here but - -do not ask for usernames in b2b software, this isn't a social network it's a pain in the ass to come up with unique ones for each account i have - -and email validation is rejecting my email -dax - -@thdxr -· -May 8, 2024 -@PlanetScale -you are so close but there's no way to stay logged into multiple emails - -i know one email can have access to many orgs but you usually have an email per org - -pain to log in and out of them instead of a quick switcher - same goes for CLI -dax - -@thdxr -· -May 8, 2024 -wow i already said all this 2 months ago -Quote -dax - -@thdxr -· -Mar 1, 2024 -every six months i come to beg of you - -in your app please support both - -- one email account can access multiple workspaces -- can be signed into multiple email accounts at the same time and switch between them - -if you need a reference implementation check linear - -please -dax - -@thdxr -· -May 8, 2024 -your data model should be - -1. workspace -2. user (belongs to workspace) -3. account (associated with many users) - -when i log in i get a token for the account - if i login multiple times save multiple tokens - -if you don't do this i don't wanna see you post any opinions till you fix -dax - -@thdxr -· -May 8, 2024 -i'm not kidding why should anyone listen to you about anything when you can't get this right diff --git a/notes/old/connections-migration.md b/notes/old/connections-migration.md deleted file mode 100644 index 4eb2d28c3..000000000 --- a/notes/old/connections-migration.md +++ /dev/null @@ -1,94 +0,0 @@ -# Connections migration (pre-refactor OAuth2 → Connection rows) - -Context for the `rs/connections` branch. Pre-refactor, the OpenAPI -plugin stored OAuth2 state inline on each source (access/refresh token -secret ids, expiresAt, tokenType, scope string, etc.) under the source's -`invocation_config.oauth2` and the top-level `openapi_source.oauth2` -column. The refactor moves all of that live state onto a new `connection` -table and narrows the source's OAuth2 config to a thin pointer -(`{kind: "oauth2", connectionId, securitySchemeName, flow, tokenUrl, -authorizationUrl, clientIdSecretId, clientSecretSecretId, scopes}`). - -## Why a data migration - -The new `OAuth2Auth` schema can't decode the legacy shape (missing -`connectionId`, extra token fields). Without a data step, existing -OpenAPI OAuth sources would decode-fail on read and lose their Sign in -button. Users would have to re-add the source from scratch. The ask was -to preserve auth state across the upgrade. - -## What the migration does, per source - -1. `INSERT INTO connection` with the new pointer's `connectionId`, - `provider = "openapi:oauth2"`, and `provider_state` = `{flow, -tokenUrl, clientIdSecretId, clientSecretSecretId, scopes}` lifted - from the legacy row. `access_token_secret_id` + `refresh_token_secret_id` - reuse the legacy secret ids — tokens themselves never move, only - ownership links flip. -2. `UPDATE secret SET owned_by_connection_id = ` for the 1–2 - referenced secret rows. -3. `UPDATE openapi_source SET oauth2 = ..., invocation_config = ...` - rewrites both OAuth2 copies to the new pointer shape. - -All three steps run in one transaction per source so a partial failure -rolls that source back cleanly and the rest continue. - -`authorizationUrl` is the one field the legacy row doesn't carry — it's -extracted at migration time from the stored OpenAPI spec -(`components.securitySchemes[name].flows.authorizationCode.authorizationUrl`). -If the `spec` column actually holds a URL (an early-branch data bug), -the plugin's `resolveSpecText` fetches it. `clientCredentials` flows -need no authorizationUrl; they migrate unconditionally. - -## Where the migration lives - -Deliberately **not** in the plugin SDK — the plugin stays on the current -shape only. Two sibling scripts own the legacy schema inline: - -- `apps/cloud/scripts/migrate-connections.ts` — standalone CLI. Default - dry-run; `--apply` runs writes. Intended to run in the deploy pipeline - after `drizzle-kit migrate`. -- `apps/local/src/server/migrate-connections.ts` — imported by - `apps/local/src/server/executor.ts` and invoked right after drizzle's - `migrate()`. Self-gates on presence of the `connection` table and the - `secret.owned_by_connection_id` column so it's inert against fresh DBs - that already boot on the new schema. - -Both scripts are idempotent — rows already on the current shape decode -cleanly and are skipped. - -Once both environments have run the migration on the real data, both -files + this note can be deleted. - -## Classification buckets - -The dry-run output mirrors the apply-mode decisions: - -- `no-oauth` — row has no OAuth2 config; skip. -- `current` — row already on the new pointer shape; skip. -- `legacy-migratable` — decodes against the legacy schema and (for - `authorizationCode`) we can recover `authorizationUrl` from the spec. - Apply-mode runs the 3-step transaction above. -- `legacy-blocked` — decodes against the legacy schema but the - `authorizationUrl` can't be recovered (corrupt spec, missing security - scheme, etc.). Dry-run reports; apply-mode halts the deploy. -- `unknown` — doesn't match either schema. Dry-run dumps the key set; - apply-mode halts the deploy. - -## Deploy sequencing - -- **Local**: drizzle migrations + this backfill both run inline at - `createLocalExecutorLayer()` boot. No manual step. -- **Cloud**: run `drizzle-kit migrate` to land `0003_add_connections.sql`, - then `bun run scripts/migrate-connections.ts` (dry-run) to confirm the - plan, then `--apply`. Auth is unavailable for the ~1–5min window - between the schema migration and the backfill completing; that window - is within tolerance. - -## Consequences of the tolerant-decode removal - -`store.ts` now uses `Schema.decodeUnknownSync(OAuth2Auth)` + a strict -`decodeInvocationConfig`. Any row that bypasses the backfill and still -holds the legacy shape will throw a `ParseError` on read. That's -intentional — it forces operator attention rather than silently dropping -auth. diff --git a/notes/old/error-handling.md b/notes/old/error-handling.md deleted file mode 100644 index 6e682bd88..000000000 --- a/notes/old/error-handling.md +++ /dev/null @@ -1,195 +0,0 @@ -# Error handling — model + plumbing - -## Principle - -Errors are typed values that propagate through the call graph carrying -their full causal structure. **Exactly one layer at the edge** consumes -them and maps them to a public response. Plugin code never imports -Sentry, never calls `captureException`, never wraps handlers with -`sanitize*` helpers. - -Two error categories: - -- **Surfaceable**: typed Schema errors with a user-actionable message - (`McpOAuthError`, `OpenApiParseError`, …). Carry through to the - response with their own status (4xx) and message body. Not captured - to any external sink — they're normal business outcomes. -- **Internal**: truly unexpected (storage failure, third-party returned - garbage, sync throw inside a handler). Captured via `ErrorCapture` - **at the HTTP edge only**, then propagated as the shared - `InternalError({ traceId })` schema — opaque to clients, fully - detailed in Sentry. One shape, one trace id, one place to look up - the cause. - -## Layering - -``` - storage-core ──▶ sdk ──▶ api (HTTP edge) ──▶ host - StorageError StorageError InternalError ErrorCaptureLive - (raw) (raw) (captured) (Sentry) -``` - -The SDK stays storage-typed. The HTTP edge (`@executor-js/api`) is the -**only** layer that translates `StorageError → InternalError` and -captures the cause to telemetry. Non-HTTP consumers (CLI, Promise -SDK, tests) see raw `StorageError` in the typed channel and can react -however they want. - -## Plumbing - -### Storage layer (`@executor-js/storage-core`) - -Emits two `Data.TaggedError` classes and nothing else observability-related: - -- `StorageError({ message, cause })` — the catch-all for non-recoverable - backend failures. The `cause` travels as runtime data so the HTTP - edge can capture it, but it's never serialised to the wire. -- `UniqueViolationError({ model? })` — typed 4xx-shaped failure plugins - want to react to (e.g. "source already exists"). - -Both are `Data.TaggedError`, not `Schema.TaggedError` — you physically -can't `addError(...)` them on an HttpApi group, which enforces "these -are internal types, not wire shapes". - -`DBAdapter` / `CustomAdapter` / `TypedAdapter` declare -`Effect` where `StorageFailure = StorageError | -UniqueViolationError`. No `Error`, no telemetry service in R. - -Storage-core has zero observability awareness — it just emits typed -values and lets consumers decide what to do with them. - -### SDK (`@executor-js/sdk`) - -The SDK is entirely observability-free. - -- `createExecutor` requires no observability service. `R = never`. -- `PluginCtx.storage`, `ctx.core.*`, `ctx.secrets.*`, `ctx.transaction` - all surface raw `StorageFailure` in the typed error channel. Plugins - can `Effect.catchTag("UniqueViolationError", …)` and translate to - their own user-facing errors. -- Executor public methods (`executor.tools.list()`, - `executor.sources.refresh()`, etc.) also surface raw `StorageFailure`. - -No `liftStorage`, no `wrapAdapterForPlugin`, no `ErrorCapture` tag -inside the SDK. The value proposition: an SDK consumer can write a CLI, -a script, a promise-based wrapper, whatever — and the typed channel -shows them exactly what can go wrong. - -### HTTP edge (`@executor-js/api/observability`) - -Owns the translation, the opaque wire schema, and the capture service. - -- `InternalError({ traceId })` — the public opaque 500 schema, with an - `HttpApiSchema.annotations({ status: 500 })` annotation so the - framework encodes it correctly. -- `ErrorCapture` — tagged Effect service for recording unexpected - causes. Shape: - - ```ts - interface ErrorCaptureShape { - readonly captureException: (cause: Cause) => Effect; - } - ``` - - Optional — resolved via `Effect.serviceOption`; missing service = - empty trace ids. Nothing breaks if it's not wired. - -- `capture(eff)` — the single translator. Catches `StorageError` on - the typed channel, captures the cause via `ErrorCapture`, fails with - `InternalError({ traceId })`. Catches `UniqueViolationError` and - dies (plugins that want to surface it as a typed domain error should - `Effect.catchTag` inside their own method first). Every other typed - failure passes through. - - Every handler wraps its generator body with `capture(...)`: - - ```ts - .handle("probeEndpoint", ({ payload }) => - capture(Effect.gen(function* () { - const ext = yield* McpExtensionService; - return yield* ext.probeEndpoint(payload.endpoint); - })), - ) - ``` - - One line at the top of each handler. No service-level proxy, no - `Captured` type gymnastics — the translation is visible right - where it happens, and TypeScript rejects handlers that forget - (because `StorageError` isn't in the group's `.addError` list). - -- `observabilityMiddleware(Api)` — defect safety net. An - `HttpApiBuilder.middleware` layer that wraps the HttpApp once and - catches any cause that escaped the typed channel (defects, - interrupts, framework bugs) via `ErrorCapture`, returning a typed - `InternalError({ traceId })`. Should rarely fire when the rest of - the pipeline is well-typed. - -### Plugin SDK - -Plugin authors write normal Effect code. Their extension method error -unions look like: - -```ts -Effect.Effect; -``` - -Where `MyPluginTypedError` is the union of their own -`Schema.TaggedError` classes (with `HttpApiSchema.annotations({ status: 4xx })`). -`StorageError` is the raw storage tag — it bubbles up, and the HTTP -edge translates it. - -Plugins never provide `ErrorCapture`, never import Sentry, never see -`InternalError` in their typed channel. - -### API groups - -Each group declares its typed errors once at the group level: - -```ts -class McpGroup extends HttpApiGroup.make("mcp") - .add(endpoint1) - .add(endpoint2) - // … - .addError(InternalError) - .addError(McpOAuthError) - .addError(McpConnectionError) { - // … -} -``` - -No per-endpoint `addError`. The framework encodes each tagged error by -its annotated status. - -### Hosts - -- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides - `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API - layer in `protected-layers.ts` so it's available to both - `observabilityMiddleware` (defect catchall) AND the per-handler - `capture(...)` translation. Service tags (`ExecutorService`, - `McpExtensionService`, etc.) hold the raw SDK shapes; the cloud app - just does `Layer.succeed(McpExtensionService, executor.mcp)` — - handlers do the translation themselves. (Distinct from - `apps/cloud/src/services/telemetry.ts`, which is the OTEL→Axiom span - bridge — "telemetry" in the tracing sense.) -- **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a - console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`) - that prints the squashed cause + pretty cause to stderr and returns a - short correlation id. -- **Tests / Promise SDK / examples** — non-HTTP consumers see raw - `StorageError` / `StorageFailure` in the SDK's typed channel and - can match on it directly. - -### Anti-patterns - -- `Effect.orDie` at handler boundaries — silently turns recoverable - failures into 500s with no telemetry (defects bypass typed-channel - encoding). -- Per-plugin `*InternalError` types — clients can't tell which plugin - emitted a 500 anyway. Use the shared `InternalError`. -- `sanitize*` helpers in handler files that `catchAllCause` + map to a - generic 500 — same swallowing problem in disguise. Prefer wrapping - the handler's `Effect.gen` body with `capture(...)`. -- SDK code importing `Sentry.captureException` or referencing - `InternalError` / `ErrorCapture` — translation lives strictly in - `@executor-js/api`. If the SDK imports it, the layering is wrong. diff --git a/notes/old/errored-sources.md b/notes/old/errored-sources.md deleted file mode 100644 index c7d408394..000000000 --- a/notes/old/errored-sources.md +++ /dev/null @@ -1,86 +0,0 @@ -# Keep Errored Sources Visible - -## What happens today - -`config-sync` (apps/local/src/server/config-sync.ts) reads -`executor.jsonc` and calls the plugin's `addSource` / `addSpec` for -each entry. If one fails (auth, network, unparseable spec), the error -is logged and the rest keep loading: - -``` -[config-sync] Failed to load source "mcp_axiom_co": MCP discovery failed: - Failed connecting to MCP server: Failed connecting via sse: - SSE error: Non-200 status code (401) -[config-sync] 3/4 source(s) synced -``` - -The row never reaches the DB. `executor.jsonc` still has it, so next -boot retries — idempotent, but from the UI's perspective the source -has silently vanished. - -## The gap - -The user sees 3 sources in the UI. They have no way to know a 4th -existed, was tried, and failed. The only record of the failure is -stderr. Reasonable people end up confused — "did it delete my source?" - -## What we want - -The source stays visible after a failed add, marked as errored with a -human-readable reason. The UI shows it with a warning badge; a retry -button calls `refresh` or re-runs the config-sync entry for just that -source. - -## Sketch - -**Schema** — extend the `source` table: - -```ts -source: { - fields: { - id: { type: "string", required: true }, - scope_id: { type: "string", required: true, index: true }, - // ...existing... - status: { type: "string", required: true, defaultValue: "healthy" }, // "healthy" | "error" - last_error: { type: "string" }, // message - last_error_at: { type: "date" }, // for UI "last tried N minutes ago" - }, -} -``` - -**config-sync** — on failure, still write a row: - -```ts -Effect.catchAll((e) => { - const message = e instanceof Error ? e.message : String(e); - return executor.sources.recordError({ source, message }).pipe(Effect.asVoid); -}); -``` - -`executor.sources.recordError` is a new core method that writes a -source row with `status="error"` and `last_error`. Plugin-specific -data (spec JSON, endpoint URL, headers) is still pulled from the -config entry, so the row is mostly populated — just no tools -attached because discovery never ran. - -**UI** — sources list badges errored entries, shows `last_error` on -hover/click, offers "Retry" which calls `executor.sources.refresh(id)`. - -## Interaction with removes - -An errored source is still user-visible, so `removeSource` should work -on it (user can delete the jsonc entry and the DB row together). Not -a special case — current `removeSource` already works regardless of -`status`. - -## Scope - -Not for the scope-refactor PR. Separate change: - -- Schema migration adds two columns. -- `executor.sources.recordError` on the core SDK. -- Wire up in config-sync. -- UI badge + retry button in the sources list. - -Doable in a day. Worth doing because silent source dropping is the -single most confusing UX we have today. diff --git a/notes/old/indexes.md b/notes/old/indexes.md deleted file mode 100644 index d5fb9289f..000000000 --- a/notes/old/indexes.md +++ /dev/null @@ -1,86 +0,0 @@ -# Index Cleanup - -Audit of `apps/cloud/drizzle/0000_lame_rage.sql` against the query -patterns in `packages/core/sdk/src/executor.ts`. Summary: mostly -over-indexed, one important miss. - -## Redundant: single-column `scope_id` indexes (× 15) - -Every scoped table has a composite PK `(scope_id, id)` AND a separate -`
_scope_id_idx`. Postgres composite btrees support leftmost- -prefix queries — `WHERE scope_id = ?` uses the PK directly. The -single-column indexes are dead weight: extra storage, extra write -amplification on every insert, never chosen by the planner when the -composite PK is available. - -Tables affected (all drop candidates): - -- `definition_scope_id_idx` -- `graphql_operation_scope_id_idx`, `graphql_source_scope_id_idx` -- `mcp_binding_scope_id_idx`, `mcp_oauth_session_scope_id_idx`, `mcp_source_scope_id_idx` -- `openapi_oauth_session_scope_id_idx`, `openapi_operation_scope_id_idx`, `openapi_source_scope_id_idx` -- `secret_scope_id_idx` -- `source_scope_id_idx` -- `tool_scope_id_idx` -- `workos_vault_metadata_scope_id_idx` - -Root cause: `core-schema.ts` and plugin schemas mark `scope_id: { -index: true }`. The drizzle generator should probably _skip_ emitting -a single-column index on a column that's already the leftmost column -of a composite primary key. Fix in two places: - -1. **Generator** (`packages/core/cli/src/generators/drizzle.ts`) — skip - emitting `index(...)` for `field.index` when the field is the first - column of a composite PK. -2. **Schemas** — drop `index: true` from the `scope_id` field - declarations (fallback if we want to be explicit). - -Cheaper to fix at the generator level — behaves correctly regardless -of what plugin authors write. - -## Missing: `memberships.organization_id` - -`memberships` has PK `(account_id, organization_id)`. Queries: - -- `WHERE account_id = ?` — "orgs for this user" — uses PK leftmost ✓ -- `WHERE organization_id = ?` — "members of this org" — **table scan** - -The second query is at least as common as the first. Fix: - -```sql -CREATE INDEX "memberships_organization_id_idx" - ON "memberships" USING btree ("organization_id"); -``` - -Or flip the PK column order, but that's a bigger migration. - -## Probably dead weight — worth auditing queries - -Nothing in the executor source currently queries by these fields alone. -Keep or drop based on where these are actually exercised (admin UIs? -analytics?): - -- `definition_plugin_id_idx` -- `source_plugin_id_idx` -- `tool_plugin_id_idx` -- `secret_provider_idx` - -If kept, leave a note on the call site so future maintainers know why. - -## Fine as-is - -- `*_source_id_idx` on child tables (`definition`, `mcp_binding`, - `graphql_operation`, `openapi_operation`, `tool`) — exercised by - `findMany(where: source_id = X)` via the scoped adapter. -- Composite `(scope_id, source_id)` would be marginally better than the - current `source_id` single-column index (lets the planner use one - index for both filters), but not worth the churn unless we're - redoing the DDL anyway. - -## Rollout - -Not blocking PR #262. Do this as a follow-up: one small PR that -(a) teaches the drizzle generator the "skip index on composite-PK- -leading column" rule, (b) regenerates the schemas, (c) adds the -`memberships_organization_id_idx`, (d) produces a single cleanup -migration. Zero code changes to the runtime. diff --git a/notes/old/mcp-testing.md b/notes/old/mcp-testing.md deleted file mode 100644 index 39ec41a13..000000000 --- a/notes/old/mcp-testing.md +++ /dev/null @@ -1,117 +0,0 @@ -# Testing the Cloud MCP Server - -Three suites cover the cloud MCP surface. Each has a specific reason to exist; -deleting one silently drops coverage that the others can't replace. - -## Suites at a glance - -| file | pool | drives | what it proves | -| ----------------------------------------------- | ----------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `apps/cloud/src/mcp-session.e2e.node.test.ts` | node | `InMemoryTransport` + SDK `Client` | engine + plugin wiring; schema drift; elicitation semantics | -| `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 | -| `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 | - -## The workerd cross-request I/O wall - -**Symptom.** In `vitest-pool-workers`, the second request to a `McpSessionDO` -instance crashes with `Cannot perform I/O on behalf of a different request -(I/O type: RefcountedFulfiller)`. - -**Cause.** `postgres.js`'s Cloudflare Workers adapter creates socket callbacks -bound to the request context that opened the socket. The MCP session DO holds a -long-lived postgres connection across its lifetime. That's fine in prod (the -DO's own context outlives any single fetch handler) and fine under Miniflare on -a real port, but `vitest-pool-workers` enforces a strict cross-request I/O -check that prod doesn't. - -**Decision.** Do not carry a request-scoped runtime branch in production code -just to satisfy the workerd-pool limitation. The workerd-pool suite only covers -edge behavior that does not require a live multi-request MCP session (CORS, -auth failures, metadata, stale-session handling). The real-port Miniflare suite -owns multi-request MCP session coverage because it exercises the same -long-lived-postgres DO runtime path that ships to Cloudflare. - -## Miniflare harness gotchas - -**Use `unstable_dev`, not `unstable_startWorker`.** `unstable_startWorker`'s -esbuild pipeline errors on transitive deps (`cross-spawn`, `mime-types`, -`isexe`, `which`) with `Could not resolve "path" / "fs" / "child_process"` -even when `nodejs_compat` is enabled. `unstable_dev` handles them. Both boot -the worker on a real port via Miniflare internally; the bundling path differs. - -**Pin IPv4.** node `fetch` resolves `localhost` to `::1` first on macOS; -miniflare binds only to IPv4. Set `ip: "127.0.0.1"` on `unstable_dev` and -construct the base URL from `worker.address` / `worker.port`. - -**Per-test timeout.** `@effect/vitest`'s `layer(env, { timeout })` covers the -layer build but not individual `it.effect` cases. Pass `30_000` as the third -arg to each `it.effect` — real-HTTP tests through the DO take ~10s each -(postgres connect + isolate startup + SDK handshake). - -**Don't import test-worker.ts from node tests.** `test-worker.ts` imports -`./mcp` which imports `cloudflare:workers`. That bubbles up through the node -ESM loader before vitest's alias fires. Keep shared test utilities -(bearer format, etc.) in a zero-dep module — see `apps/cloud/src/test-bearer.ts`. - -## MCP elicitation in this codebase - -**Execute-tool bridge.** `packages/hosts/mcp/src/server.ts` — `executeCode` -checks `supportsManagedElicitation(server)` (requires the client to advertise -`capabilities.elicitation.form`). When true it passes -`{ onElicitation: makeMcpElicitationHandler(server) }` into `engine.execute`. -Handler body: `server.server.elicitInput(params)` over the MCP transport. - -**What triggers it.** Two distinct paths, both hit the same handler: - -1. **Executor-level approval.** `executor.ts` → `enforceApproval` runs before - `invokeTool` when `annotations.requiresApproval` is set. Fires a form - elicit with the tool's `approvalDescription`. -2. **Plugin-level elicit.** Static plugin tools get an `elicit` arg in their - handler (see `elicitingTestPlugin` in `mcp-session.e2e.node.test.ts`). - -**openApiPlugin elicits for non-GET ops.** `invoke.ts` → `annotationsForOperation` -marks POST/PUT/PATCH/DELETE with `requiresApproval: true`. When user code -invokes such an op, the executor's `enforceApproval` fires. (This is subtle -because `invoke.ts` itself doesn't call `elicit()` directly — the executor -wrapper does.) - -## Driving elicitation from an e2e test - -Pattern used in `mcp-miniflare.e2e.node.test.ts`: - -1. Stand up a tiny `HttpApi` with `HttpApiGroup` + `HttpApiEndpoint.post` via - `@effect/platform`. Implement the handler with `HttpApiBuilder.group`. -2. Serve it via `HttpApiBuilder.serve()` + - `NodeHttpServer.layer(createServer, { port: 0, host: "127.0.0.1" })`. -3. Read the bound port from `HttpServer.HttpServer.address` (TcpAddress tag) - inside a `Layer.effect` that produces the test service. -4. Generate the spec with `OpenApi.fromApi(api)` and inject - `servers: [{ url: `http://127.0.0.1:${port}` }]`before`JSON.stringify`. -5. SDK Client advertises `capabilities: { elicitation: { form: {} } }` and - registers `client.setRequestHandler(ElicitRequestSchema, ...)` that returns - `{ action: "accept", content: {} }`. -6. Call `execute` with code that (a) calls `tools.executor.openapi.addSource({ spec, -namespace })`, (b) invokes the POST operation. - -**Tool id format inside the `execute` sandbox.** -`tools...` for openapi. The Effect -`HttpApiGroup.make("approve")` name becomes part of the path, so an endpoint -`approveThing` under group `approve` under source `approveapi` is -`tools.approveapi.approve.approveThing({})`. The executor's tool row id is -literally `..`. - -**Why the invoke must run in the same `execute` call as `addSource`.** Both -tests it: the write commits immediately, and a second `execute` call in the -same session sees the new tool. Keeping them together is just convenient. - -## Things that looked like bugs but weren't - -- "The openapi plugin doesn't elicit." It does, indirectly, through the - executor's `enforceApproval` wrapper. `invoke.ts` doesn't have an `elicit()` - call — easy to miss on a shallow grep. -- "Tools added via `addSource` aren't visible." The tools proxy in the dynamic - worker module (`__makeToolsProxy`) is a recursive `Proxy` — every - `.x.y(args)` access turns into a `__dispatcher.call("x.y", ...)`. There's no - pre-computed allow-list, so newly-added tools are immediately callable. If - `ToolNotFoundError` fires with a plausible-looking id, the id format is - probably wrong (see group-name note above), not a visibility bug. diff --git a/notes/old/pluggable-storage.md b/notes/old/pluggable-storage.md deleted file mode 100644 index 23a7e3632..000000000 --- a/notes/old/pluggable-storage.md +++ /dev/null @@ -1,165 +0,0 @@ -# Pluggable Core Storage - -Today plugins (OpenAPI, MCP, GraphQL, WorkOS Vault) each expose a typed -`*Store` interface with named methods (`upsertSource`, `removeSource`, -etc.) that users can override wholesale or decoratively. The core -tables (`source`, `tool`, `secret`, `definition`) are the odd one out: -the executor reaches into them via `core.create({ model: "tool", data })` -on a raw `DBAdapter`. - -Two problems that fall out of that: - -1. **Sidecar integrations need string-matching.** A user who wants to - mirror tool writes to Algolia has to write `if (model === "tool")` - inside an adapter wrapper. Brittle; no type help. -2. **Inconsistency.** Two patterns for the same job — plugins use - typed stores, core does not. Contributors have to learn both. - -## The refactor - -Introduce `CoreStore`, modelled on the existing plugin stores. - -```ts -export interface CoreStore { - // sources - readonly putSource: (source: StoredSource) => Effect.Effect; - readonly getSource: (id: string) => Effect.Effect; - readonly listSources: () => Effect.Effect; - readonly removeSource: (id: string) => Effect.Effect; - - // tools - readonly putTools: (tools: readonly StoredTool[]) => Effect.Effect; - readonly getTool: (id: string) => Effect.Effect; - readonly listTools: () => Effect.Effect; - readonly removeToolsBySource: (sourceId: string) => Effect.Effect; - - // definitions - readonly putDefinitions: (defs: readonly StoredDefinition[]) => Effect.Effect; - readonly listDefinitionsBySource: ( - sourceId: string, - ) => Effect.Effect; - readonly removeDefinitionsBySource: (sourceId: string) => Effect.Effect; - - // secrets (routing rows; provider-specific data lives in provider) - readonly putSecret: (secret: StoredSecret) => Effect.Effect; - readonly getSecret: (id: string) => Effect.Effect; - readonly listSecrets: () => Effect.Effect; - readonly removeSecret: (id: string) => Effect.Effect; -} - -export const makeDefaultCoreStore = (deps: { adapter: DBAdapter; scope: Scope }): CoreStore => { - /* typedAdapter wiring, one place */ -}; -``` - -## Wiring — defaults passed in deps, force the spread - -Same ergonomics as the future `search` hook and the existing plugin -`storage:` options (which should be renamed to match, see below): - -```ts -export interface CoreStorageDeps { - readonly adapter: DBAdapter; - readonly scope: Scope; - readonly defaults: CoreStore; -} - -export interface ExecutorConfig { - // ... - readonly coreStorage?: (deps: CoreStorageDeps) => CoreStore; -} - -// Inside createExecutor: -const defaults = makeDefaultCoreStore({ adapter, scope }); -const core = config.coreStorage ? config.coreStorage({ adapter, scope, defaults }) : defaults; -// Executor now calls core.putTools(...), core.removeSource(...), etc. -// No more adapter.create({ model: "tool", ... }) scattered through executor.ts. -``` - -**Why defaults-in-deps, not `Partial`:** - -- Partial overrides silently fall back on typos (`putTool` vs `putTools`) - with no type help. -- Anyone implementing from scratch (test doubles, a non-drizzle backend) - gets no squigglies when they miss a method. -- Defaults-in-deps gets "only write what you override" ergonomics _and_ - keeps the full `CoreStore` return type enforced. - -## What it unlocks - -**Algolia sidecar — no string-match, just method overrides:** - -```ts -const executor = - yield * - createExecutor({ - scope, - adapter, - blobs, - plugins, - - coreStorage: ({ defaults }) => ({ - ...defaults, - putTools: (tools) => - Effect.gen(function* () { - yield* defaults.putTools(tools); - yield* Effect.promise(() => - algolia.saveObjects({ - indexName: `tools_${scope.id}`, - objects: tools.map((t) => ({ objectID: t.id, ...t })), - }), - ); - }), - removeToolsBySource: (sourceId) => - Effect.gen(function* () { - const affected = yield* defaults.listTools(); - yield* defaults.removeToolsBySource(sourceId); - yield* Effect.promise(() => - algolia.deleteObjects({ - indexName: `tools_${scope.id}`, - objectIDs: affected.filter((t) => t.sourceId === sourceId).map((t) => t.id), - }), - ); - }), - }), - }); -``` - -Test doubles become trivial — either hand-roll the whole `CoreStore` -against a `Map`, or override just the couple of methods a test cares -about. - -## Aligning plugin `storage:` options - -Today each plugin exposes `storage: (deps) => makeDefaultOpenapiStore(deps)`. -The factory name leaks. Rename to the defaults-in-deps shape: - -```ts -// before -storage: (deps) => makeDefaultOpenapiStore(deps); - -// after -storage: ({ defaults }) => defaults; // no-op, identical to leaving off `storage:` - -// override one method -storage: ({ defaults }) => ({ ...defaults, upsertSource: myUpsert }); -``` - -Mechanical sweep across openapi / mcp / graphql / workos-vault plugins. -Worth doing in the same PR so there's one pattern everywhere. - -## Scope of the refactor - -Not intellectually hard, but a broad diff: - -- Every `core.create / findOne / findMany / upsert({ model: "X" })` call - in `packages/core/sdk/src/executor.ts` becomes a named method on - `CoreStore`. That's the bulk of the work. -- `makeDefaultCoreStore` is the one place the model-string wiring lives. -- Plugin `storage:` signature update + every plugin's default store - factory renamed to follow the `defaults` contract. -- Docs/readme updates showing the new override pattern. - -Ideally lands _before_ pluggable search — the search sidecar story -depends on this refactor to avoid the `if (model === "tool")` antipattern -I'd otherwise be stuck with. diff --git a/notes/old/promise-sdk-typed-errors.md b/notes/old/promise-sdk-typed-errors.md deleted file mode 100644 index d0768f1d4..000000000 --- a/notes/old/promise-sdk-typed-errors.md +++ /dev/null @@ -1,80 +0,0 @@ -# Promise SDK — typed errors revisit - -## Today - -`@executor-js/sdk/promise` wraps every Effect-returning method in -`Effect.runPromise(...)`. That works but it's lossy at the Promise -boundary: - -- The promise's _rejection type_ is `unknown` (or `any` after - `try/catch`). Consumers have to `instanceof InternalError` / - `err._tag === "..."` to discriminate, even though the underlying - Effect had a fully typed error union. -- Defects (uncaught throws, fiber interrupts) become rejections with - whatever value the Effect ran with — sometimes useful, sometimes a - string, depending on the cause. - -In other words: the Effect-side surface knows your method can fail with -`McpOAuthError | InternalError | UniqueViolationError`; the promise -consumer sees `Promise` and has to take it on faith. - -## What we want (revisit) - -Switch the wrapper to `Effect.runPromiseExit(...)`. The promise always -resolves with an `Exit`: - -```ts -const exit = await executor.openapi.addSpec({ ... }) -if (Exit.isSuccess(exit)) { - exit.value // properly typed `A` -} else { - // exit.cause is Cause - // narrow with Cause.failureOption / Cause.match etc. -} -``` - -The error union survives the Promise boundary. Defects are visible in -the cause. Consumers can write totality-checked `match` on the typed -union without any runtime guessing. - -## Open question for the revisit - -Do we expose Effect's `Exit` / `Cause` directly to promise consumers, -or wrap them in a promise-native `Result`? - -**Expose `Exit`/`Cause`:** - -- Pros: one fewer abstraction, no translation layer to maintain, cause - preserves parallel/sequential composition. -- Cons: consumers depend on `effect`'s API even though they're using a - "Promise" SDK — partly defeats the abstraction. - -**Wrap in a `Result`:** - -- Pros: `@executor-js/sdk/promise` consumers don't import `effect` at all, - surface stays small. -- Cons: another type to learn / document; loses some Cause structure - (parallel/interrupt) unless we replicate it. - -Lean toward exposing `Exit`/`Cause`, but worth thinking through what -the `runPromiseExit` consumers actually want to do at the call site -before deciding. - -## Why punted - -Current refactor is about getting Effect-side typed errors right -end-to-end (storage → SDK → API). The Promise façade is downstream of -that — once the typed unions are stable on the Effect side, the -`runPromiseExit` rewrite is a small, well-defined change. - -Also: it's a breaking change for promise SDK consumers (return type -goes from `Promise` to `Promise>`). Better as its own -focused PR with a migration note. - -## Files affected when we do it - -- `packages/core/sdk/src/promise-executor.ts` (`promisifyDeep`, - `createExecutor`) -- `packages/core/sdk/src/promise.ts` if it re-exports surface -- Any Promise-SDK consumer in `examples/` or downstream -- Doc updates in `notes/error-handling.md` diff --git a/notes/old/rls.md b/notes/old/rls.md deleted file mode 100644 index 14dda8a1d..000000000 --- a/notes/old/rls.md +++ /dev/null @@ -1,80 +0,0 @@ -# Postgres RLS — optional, not required - -## TL;DR - -**RLS is not needed for the current architecture.** Tenant isolation is -enforced at the application layer: every query against a scoped table -(see `notes/scopes.md`) is routed through the scope adapter which ANDs -`scope_id = ` into every read and stamps it on every -write. Only the worker talks to the DB; clients authenticate to the -worker via WorkOS JWT, never to Postgres directly. One DB role -(`postgres`) owns every row. RLS would be a second lock on a door that's -already locked. - -The only reason to turn RLS on is **defense-in-depth** — a belt against -a future bug where a drizzle query forgets to filter by `scope_id`. -Without RLS, such a query silently returns cross-tenant rows. With RLS, -it returns zero rows. - -## When RLS matters - -RLS earns its weight when untrusted clients connect directly to Postgres -with per-user credentials (the Supabase / PostgREST model). Not our -shape. - -Consider turning it on if any of these change: - -- Clients start hitting Postgres directly (JWT-authed session, row-level - policies as the only authorisation layer). -- We run multiple DB roles that need row-level isolation between them. -- We decide the cost of a missed `scope_id` filter in app code is high - enough to want a DB-level backstop. - -## How it would work with scope merging - -Scope merging (`ScopeStack` in `notes/scopes.md`) means reads fan out -across multiple scopes while writes target exactly one. RLS policies -mirror that asymmetry by reading two settings per request: - -```sql -CREATE POLICY scope_read ON source FOR SELECT - USING (scope_id = ANY(current_setting('app.scope_chain', true)::text[])); - -CREATE POLICY scope_write ON source FOR INSERT - WITH CHECK (scope_id = current_setting('app.write_scope', true)); - -CREATE POLICY scope_update ON source FOR UPDATE - USING (scope_id = current_setting('app.write_scope', true)) - WITH CHECK (scope_id = current_setting('app.write_scope', true)); - -ALTER TABLE source ENABLE ROW LEVEL SECURITY; -``` - -And app-side, once per request inside a transaction (so `SET LOCAL` -takes): - -```ts -yield * sql`SET LOCAL app.scope_chain = ${toPgArray(chain)}`; -yield * sql`SET LOCAL app.write_scope = ${writeScope}`; -``` - -`current_setting('…', true)` returns null when the setting is missing, -which is what you want — a connection that forgets to configure the -scope sees zero rows, not an error that might be handled into a 500. - -## Rollout sketch (if we ever decide to enable it) - -1. Add a migration that `ENABLE ROW LEVEL SECURITY` on every scoped - table and installs the read/write policies above. The list of scoped - tables is whatever `executor-schema.ts` exports with a `scope_id` - column — same set the scope adapter already knows about. -2. Add an Effect layer that wraps the DbService to issue the two `SET -LOCAL`s at connection acquire. `postgres.js`'s `sql.begin` already - gives us the transaction boundary. -3. Leave `BYPASSRLS` off the worker's role so we actually get the - enforcement; grant it on the role used by the migration / admin - scripts. - -Ballpark effort: a day including the migration + the Effect layer + -tests. Worth doing only if we hit a scope-leak bug in prod or change -the access model. diff --git a/notes/old/scoped-source-auth.md b/notes/old/scoped-source-auth.md deleted file mode 100644 index e69c0a7d4..000000000 --- a/notes/old/scoped-source-auth.md +++ /dev/null @@ -1,214 +0,0 @@ -# Scoped source auth and plugin-owned overrides - -Working model for shared sources with personal/workspace/org auth. - -## Problem - -Sources can be shared at an outer scope while auth often belongs at an -inner scope. - -Examples: - -- An organization adds an OpenAPI source once, but every member brings - their own API token or OAuth client credentials. -- A workspace shares one connection by default, but an individual user - overrides it with their own connection. -- A future policy says an org only allows low-risk operations to auto-run, - while a user or workspace has a narrower or broader typed policy. - -The common primitive is not "org auth" or "user auth". It is an ordered -scope stack: - -```ts -[user, workspace, org]; -``` - -Rows still belong to exactly one scope. Resolution walks the stack -innermost first, with outer scopes acting as shared defaults. - -## Boundary - -Core owns generic primitives: - -- Scope stack and scope ids. -- Source registry metadata: id, owning scope, plugin id, display fields. -- Secrets and connections. -- Tool invocation enforcement once a plugin reports annotations/policy. - -Plugins own source-domain meaning: - -- How a source authenticates. -- What auth slots exist. -- How slots are used during invocation. -- Per-source/per-scope config patches. -- Per-source/per-scope policy rules. - -Do not put plugin-specific config into a generic core JSON bag. Core -should not know that an OpenAPI source uses an Authorization header, that -an OAuth flow is client credentials, or that `GET` operations are safer -than `POST` operations. Those are plugin concerns. - -## OpenAPI model - -OpenAPI has a typed base source row: - -```ts -openapi_source { - id - scope_id - spec - base_url - headers // static strings or typed slot references - oauth2 // typed OAuth template with slot ids -} -``` - -The source row describes the template, not a globally connected account. -For example: - -```ts -oauth2: { - flow: ("clientCredentials", tokenUrl, clientIdSlot, clientSecretSlot, connectionSlot, scopes); -} -``` - -Scoped auth material lives in OpenAPI-owned rows: - -```ts -openapi_source_binding { - source_id - source_scope_id - target_scope_id - slot - value: { kind: "secret", secretId } - | { kind: "connection", connectionId } - | { kind: "text", text } -} -``` - -The storage column is intentionally `target_scope_id`, not `scope_id`. -This keeps the table out of the SDK scoped-adapter convention, because a -source owner must be able to clean up all bindings for that source even -when some bindings target descendant user scopes. OpenAPI still filters -and validates visibility manually against the active scope stack. - -OpenAPI resolves bindings across the current scope stack. The plugin then -uses its typed source config to decide how the resolved material is -applied. For example, a slot may become an HTTP header with a prefix, or -an OAuth connection may provide a bearer token. - -Core is only involved when resolving the underlying primitive: - -```ts -const binding = await openapiStore.resolveSourceBinding(source, slot); - -if (binding.value.kind === "secret") { - const value = await ctx.secrets.get(binding.value.secretId); -} - -if (binding.value.kind === "connection") { - const token = await ctx.connections.accessToken(binding.value.connectionId); -} -``` - -## Add-source UX - -Adding a source and adding auth should be separable. - -Expected flow: - -1. Admin adds a source at an outer scope. -2. Admin chooses the auth template the source supports. -3. Source can be saved without entering secret values or completing OAuth. -4. Users or workspace admins later fill the auth slots at the scope they - want to own. -5. Invocation resolves the innermost matching binding. - -This avoids making the first person who adds a source accidentally -provide auth for everyone. - -## OAuth flows - -Authorization code and client credentials use the same slot model. - -Authorization code: - -```ts -source oauth template: - clientIdSlot - clientSecretSlot? - connectionSlot - -user bindings: - clientIdSlot -> shared or personal client id secret - clientSecretSlot -> shared or personal client secret secret - connectionSlot -> user's Connection -``` - -Client credentials: - -```ts -source oauth template: - clientIdSlot - clientSecretSlot - connectionSlot - -user bindings: - clientIdSlot -> user's client id secret - clientSecretSlot -> user's client secret secret - connectionSlot -> user's app-style Connection -``` - -`Connection.kind` remains a core concept because it describes the -connection/token identity. The pointer from a source auth slot to a -connection is plugin-owned scoped data. - -## MCP direction - -MCP should follow the same shape when it needs shared source plus -personal auth: - -- MCP source row owns the typed auth template. -- MCP plugin owns scoped auth binding/config rows. -- Core connections/secrets remain the underlying primitives. -- Invocation resolves through the plugin-owned scoped rows. - -Do not make MCP depend on OpenAPI's binding table. The shared abstraction -is the scope stack and the helper patterns, not a cross-plugin table that -contains plugin semantics. - -## Policy direction - -Auto-run/approval policy should not be stored as generic source override -JSON. - -Core should enforce the final invocation decision, but plugins should own -typed policy rules when the rule language is domain-specific. For -OpenAPI, a policy may mention HTTP methods or operation ids. For MCP, it -may mention MCP tool metadata. Those meanings are not core concepts. - -Shape: - -```ts -plugin scoped config/policy rows: - source_id - source_scope_id - target_scope_id // or another plugin-owned scope field, not core magic - typed plugin policy fields - -plugin.resolveAnnotations(tool, scopeStack): - derive ToolAnnotations from plugin-owned rows - -core invoke: - enforce ToolAnnotations -``` - -## What not to do - -- Do not add a generic `source_override.value` JSON blob in core for - plugin config. -- Do not store effective plugin config twice, once in plugin tables and - once in core. -- Do not make slot names meaningful to core. -- Do not bake user/org/workspace concepts into SDK storage. Scope ids are - flat; the host builds the ordered stack per request. diff --git a/notes/old/scopes.md b/notes/old/scopes.md deleted file mode 100644 index 654b568de..000000000 --- a/notes/old/scopes.md +++ /dev/null @@ -1,187 +0,0 @@ -# Scopes - -Tenant isolation + the path to layered scopes. - -## Today: flat, one scope per executor - -Every multi-tenant row carries a `scope_id` column. Tables whose schema -declares `scope_id` are "scoped"; tables without it are shared across -scopes by construction. - -- **SDK** — `createExecutor({ scope: Scope, adapter, blobs, plugins })`. - One `Scope` per executor instance. `scopeAdapter(rootAdapter, {...}, -schema)` wraps the adapter before it reaches plugin storage or the - core-table writers. Every read on a scoped table gets `where scope_id -= scope.id` ANDed in; every write gets `scope_id = scope.id` stamped - into the payload. Tx handles passed into transaction callbacks are - also wrapped, so nested writes stay inside the same scope. - -- **Plugins** — see a plain `DBAdapter`. They do not know or care about - scope. Every plugin schema that wants isolation declares `scope_id: -{ type: "string", required: true, index: true }`. Forgetting the - column means the adapter passes the plugin's reads/writes through - unscoped (documented failure mode, not silent) — tests catch the - concrete cases we care about. - -- **Cloud** — the WorkOS organization is the outermost scope. - `createScopedExecutor(scopeId, scopeName)` in - `apps/cloud/src/services/executor.ts` is called per request with - `{ org.id, org.name }` pulled from the session. One scope per request. - -- **Local** — `apps/local/src/server/executor.ts` derives a single scope - id from the working directory (`${basename(cwd)}-${sha256(cwd)[0..8]}`). - One scope per executor process. - -Coverage for the invariant lives in two places: `packages/core/sdk/src/ -executor.test.ts` (SDK-level, in-memory adapter, two executors sharing -one adapter) and `apps/cloud/src/services/tenant-isolation.node.test.ts` -(HTTP-level, real `ProtectedCloudApi`, real PGlite). Both exercise -sources, tools, secrets, and plugin-owned source detail lookups. - -## The primitive already accepts layering - -`scopeAdapter` takes a `ScopeContext`: - -```ts -interface ScopeContext { - readonly read: readonly string[]; // precedence-ordered, innermost first - readonly write: string; // exactly one scope for writes -} -``` - -Today the read list is always length 1 and equals the write target. For -a single scope the wrapper emits `where scope_id = `; for multiple -scopes it emits `where scope_id IN (...)`. The shape is a list on -purpose so that extending to a stack later touches **one** call site -(`createExecutor`'s adapter wrap) — not every plugin or every storage -backend. - -Read-side dedup by id (shadowing on collision) is **not implemented** -today; no code path sees rows from more than one scope yet. When -layering lands the dedup step is a thin pass on top of `findMany` / -`list` results. - -Write target is always a single scope. Layered writes mean "I decided -my write target is workspace, not org, not user" — that's a policy -decision the caller makes, not something storage guesses. - -## Future: layered scopes - -The goal is a design like: - -``` -org → workspace → workspace-of-workspace → user -``` - -As an ordered list, not a tree. Rows stay owned by exactly one scope. -Reads walk the list; on id collision the innermost wins (shadowing). -Writes land in exactly one scope chosen by the caller. - -### Scenarios we want to support - -- **Org-provided API key, team inherits.** Org admin adds an OpenAPI - source with its auth at the org scope. Every user in every workspace - in that org sees the source and the auth. Override at any inner scope - (e.g., workspace overrides the URL, user overrides nothing) by - writing a row with the same id at the inner scope — the outer row is - shadowed on read. - -- **Workspace Gmail, per-user auth.** Workspace admin adds the Gmail - source at workspace scope but declines to store an oauth token - there. When a user invokes Gmail, secret resolution walks the scope - stack (user → workspace → org) and only finds a token if that user - personally oauthed. Policy on the source row — `auth_scope_mandate: -"user"` — forces this: secret lookup refuses to return - workspace-level tokens even if one existed. - -- **Local global + per-folder.** `~/.executor/global` holds the outer - scope; the current folder's `executor.jsonc` holds the inner. Run - `executor` in folder A and you see folder A's sources layered on the - global set. Run in folder B, same global base, different inner. - -### Data model implications - -- **No new columns on existing tables.** `scope_id` is enough — each - row still belongs to exactly one scope. Layering is a read-time and - resolution-time concern, not a storage concern. - -- **One new column** on `source` (or whatever the public config table - becomes): `auth_scope_mandate: string | null`. If set, secret - resolution for this source refuses scope levels at or above the - mandate. `null` means "resolve normally, inner wins." - -- **No parent pointer.** Do not add `parent_scope_id`. Parent pointers - imply a tree; we want an ordered list held in memory per request. - Scope identity is flat (each scope is just a string id); hierarchy - is assembled by the host app per request. - -### API / SDK surface changes when layering lands - -- `Scope` becomes `ScopeStack { read: readonly Scope[]; write: Scope }`, - or `createExecutor` grows a new shape and the old `Scope` path becomes - a 1-element convenience. -- `ctx.scope.id` in plugin code is an implicit "the scope" today. The - three callers in `executor.ts` that set `SecretRef.scopeId` from - `scope.id` need to read the write target instead: `ctx.scope.write.id` - or a rename. -- `scopeAdapter` signature is already list-shaped. Change the caller, - not the wrapper. -- Secret resolution (`executor.secrets.get`) grows a shadowing pass: - walk the stack, first non-null value wins. If the source has an - `auth_scope_mandate`, skip scopes above it. - -### Permissions / RBAC - -Out of scope for the isolation primitive. Who can write to which scope -is a host-app concern (cloud: WorkOS role + workspace membership; -local: filesystem ownership). The SDK just takes the caller's declared -write target on faith — the host is responsible for enforcing that -it's legal. - -### Cloud glue - -`createScopedExecutor` takes `(scopeId, scopeName)` today with the -cloud passing the org id/name. When workspaces arrive: - -- The request's `AuthContext` grows `workspaceId?` and `userId`. -- Cloud builds a `ScopeStack` from those: `[user, workspace, org]` read, - write defaulting to the innermost writable (usually user; admin ops - override). -- `createScopedExecutor` becomes `createScopedExecutor(scopes: ScopeStack)` - or a similar shape. - -The storage layer and every plugin are untouched by that change. - -### Local glue - -`apps/local` derives a single scope id from `cwd` today. Layering is -additive: - -- Outer scope: `~/.executor/global` (or a named profile). -- Inner scope: the current folder, same derivation as today. -- `executor.jsonc` sync writes to the inner (folder) scope by default; - a `--global` flag or explicit file location targets the outer. - -## What NOT to do - -- Do not collapse `source` and `secret` into one table to simplify - layering. Their scope targets are independently chosen (see the - Gmail scenario). -- Do not add a `parent_scope_id` column. -- Do not bake the concept of "organization" into SDK types or storage. - It's a cloud concern today that maps onto the generic `scope_id`; - the SDK must stay scope-generic so workspace + user scopes are - cheap to add. -- Do not introduce scope-level defaults in the wrapper. The wrapper - stamps and filters; shadowing lives one layer up. - -## Related - -- `packages/core/sdk/src/scoped-adapter.ts` — the wrapper. -- `packages/core/sdk/src/core-schema.ts` — `scope_id` on source, tool, - definition, secret. -- `packages/core/sdk/src/executor.test.ts` — SDK-level tenancy tests. -- `apps/cloud/src/services/tenant-isolation.node.test.ts` — HTTP-level - tenancy tests. -- Per-plugin schemas all carry `scope_id` today: - openapi, mcp, graphql, google-discovery, workos-vault. diff --git a/notes/old/search.md b/notes/old/search.md deleted file mode 100644 index 13877f107..000000000 --- a/notes/old/search.md +++ /dev/null @@ -1,136 +0,0 @@ -# Pluggable Tool Search - -The path to making `tools.search()` swappable (Algolia, pg-fts, trigram, -whatever) without baking any of it into the core. - -## Today: in-memory linear scan - -`searchTools` in `packages/core/execution/src/tool-invoker.ts`: - -1. `executor.tools.list()` — `SELECT * FROM tool WHERE scope_id = ?`. -2. In JS, for every tool: normalize + tokenize `id / sourceId / name / -description`, score per field with weights, apply coverage + exact- - phrase boosts, filter, sort, slice. - -O(N) per query over the scope. Fine at hundreds of tools, painful past -a few thousand. Pulls full rows (including JSONB `input_schema` / -`output_schema`) which aren't even used for scoring — wasted I/O. - -## Interface - -One method. No `key` — there's only one search provider per executor, -nothing to route. - -```ts -export interface ToolSearchProvider { - readonly search: (q: SearchQuery) => Effect.Effect; -} - -export interface SearchQuery { - readonly scopeId: string; - readonly query: string; - readonly namespace?: string; - readonly limit: number; -} - -export interface SearchMatch { - readonly id: string; - readonly sourceId: string; - readonly name: string; - readonly description: string; - readonly score: number; -} -``` - -Call site: `executor.search.query(q)`. (Rename the inner method from -`search` to `query` so we don't get stuck with `executor.search.search(q)` -and still have room to grow the surface — `reindex`, `invalidate` — later.) - -## Wiring — force the spread - -```ts -export interface SearchDeps { - readonly listTools: (scopeId: string) => Effect.Effect; - readonly defaults: ToolSearchProvider; -} - -export interface ExecutorConfig { - // ... - readonly search?: (deps: SearchDeps) => ToolSearchProvider; -} -``` - -Why `listTools` instead of the whole `Executor`: breaks the chicken-and- -egg (executor can't be fully constructed before its search provider is) -and keeps the provider contract tight. - -Why "force the spread" instead of `Partial`: partial -overrides silently fall back when a method name gets typoed, and -implementing from scratch gets no type hint when a method is missed. -Forcing a full return value via `defaults` in deps gives us both the -"just write what you override" ergonomics and a complete `ToolSearchProvider` -type. Same pattern as plugin `storage:` options and the future `coreStorage:`. - -## Override shapes - -**Replace entirely:** - -```ts -search: ({ listTools }) => algoliaSearch({ listTools }), -``` - -**Decorate — mix in logging/timing:** - -```ts -search: ({ defaults }) => ({ - query: (q) => Effect.gen(function* () { - const start = Date.now(); - const hits = yield* defaults.query(q); - console.log(`search ${q.query} scope=${q.scopeId} ms=${Date.now()-start}`); - return hits; - }), -}), -``` - -**Hybrid — try Algolia, fall back to local:** - -```ts -search: ({ listTools, defaults }) => ({ - query: (q) => - algoliaSearch({ listTools }).query(q).pipe( - Effect.catchAll(() => defaults.query(q)), - ), -}), -``` - -## Indexing side — separate concern - -Answering from Algolia/Elasticsearch means keeping an external index in -sync with tool writes. That doesn't belong in the `ToolSearchProvider` — -it's a storage concern. Handled by wrapping the core store (see -`pluggable-storage.md`): override `putTools` / `removeToolsBySource` to -delegate to the default and mirror to the external index. - -The two halves are independent: - -- Indexing only (manual reindex): override `coreStorage`, leave `search` - as default. -- Search only (some other populates the index): override `search`, leave - `coreStorage` as default. - -## Cheap wins before external search - -Before reaching for Algolia, there's low-hanging fruit on the built-in -scorer: - -1. **Projection on `tools.list`** — expose a `select` option so search - doesn't pull `input_schema` / `output_schema`. Biggest per-query win. -2. **Postgres FTS** — `tsvector` generated column on `(name, description, -id, source_id)`, GIN index, use `websearch_to_tsquery`. Native - ranking, fuzzy via `pg_trgm`. Would replace the in-memory scorer for - the cloud app. -3. **Per-scope LRU** — cache scored results; invalidate on source - add/update/remove. - -The pluggable interface lets us land these as just another provider -(`pgFtsSearch({ db })`) without touching the default path. diff --git a/notes/old/storage-migration.md b/notes/old/storage-migration.md deleted file mode 100644 index bf1bd0e68..000000000 --- a/notes/old/storage-migration.md +++ /dev/null @@ -1,82 +0,0 @@ -# Storage Migration Notes (PR #262 — sdk-refactor-inplace) - -## What was done - -### 1. DBSchema alignment with better-auth - -- Vendored better-auth's `BetterAuthPluginDBSchema` pattern into `storage-core/src/schema.ts` -- Made `modelName` optional (falls back to the key), renamed `disableMigrations` → `disableMigration`, dropped `order` -- Removed explicit `modelName` from all plugin schemas (core-schema, openapi, mcp, graphql, google-discovery, workos-vault) — keys already match table names - -### 2. @executor-js/cli package (packages/core/cli/) - -- Mirrors better-auth's CLI approach: load config → collect plugin schemas → generate drizzle TS -- `executor generate --config ./executor.config.ts --output ./src/services/executor-schema.ts` -- Generator ported from better-auth's drizzle generator, adapted for our DBSchema -- Handles pg/sqlite/mysql dialects, indexes, references, relations, default values -- Uses jiti for config loading (supports TS configs) - -### 3. apps/cloud wired up - -- `executor.config.ts` defines plugins with stub credentials (only used for schema shape) -- `drizzle.config.ts` points at both `schema.ts` (cloud tables) and `executor-schema.ts` (executor tables) -- Fresh `drizzle/0000_initial.sql` with all tables (cloud + core + plugin + blob), indexes, FKs - -### 4. apps/local wired up - -- `executor.config.ts` defines plugins + dialect sqlite -- `drizzle.config.ts` for drizzle-kit generate/push -- Generated `executor-schema.ts` with all sqlite tables + blob table -- `drizzle/0000_*.sql` initial migration -- At startup: `migrate(db, { migrationsFolder })` from `drizzle-orm/bun-sqlite/migrator` applies pending migrations before constructing the adapter - -### 5. Adapter DDL removed - -- `makeSqliteAdapter` and `makePostgresAdapter` no longer run DDL — they return `DBAdapter` directly (not `Effect.Effect`) -- `makeSqliteBlobStore` and `makePostgresBlobStore` no longer run DDL — they return `BlobStore` directly -- `buildCreateTableStatements` deleted from `storage-file/compile.ts` -- Blob table included in both local and cloud generated schemas — managed by drizzle-kit alongside all other tables - -## Migration path — what's next - -### The plan: re-derive sources from executor.jsonc - -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. - -### Local app migration - -1. On first run, `migrate()` creates all tables from the initial migration -2. Add a **sync-from-config step** after executor creation: - - `loadConfig(configPath)` reads `executor.jsonc` - - For each source, call the plugin's add method (`executor.openapi.addSpec()`, `executor.mcp.addSource()`, etc.) - - This re-fetches specs, re-parses tools, populates typed tables -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. - -### What won't survive (acceptable losses) - -- Tool invocation history / blobs (old KV format) -- OAuth sessions (ephemeral, users re-auth) -- Stale source state (re-fetching is actually a benefit) - -### Cloud migration - -- Only ~2 users, just drop old tables and re-add manually -- Or write a one-off SQL migration - -## Key files - -| File | Role | -| --------------------------------------------- | ----------------------------------------- | -| `packages/core/cli/src/commands/generate.ts` | CLI generate command | -| `packages/core/cli/src/generators/drizzle.ts` | Drizzle schema generator | -| `packages/core/sdk/src/config.ts` | `defineExecutorConfig` helper | -| `packages/core/config/src/schema.ts` | `executor.jsonc` schema | -| `packages/core/config/src/config-store.ts` | Plugin store → config file decorator | -| `packages/core/config/src/load.ts` | Config loading | -| `apps/cloud/executor.config.ts` | Cloud config for CLI | -| `apps/cloud/src/services/executor-schema.ts` | Generated drizzle schema | -| `apps/cloud/drizzle/0000_*.sql` | Fresh migration | -| `apps/local/executor.config.ts` | Local config for CLI | -| `apps/local/src/server/executor-schema.ts` | Generated drizzle schema | -| `apps/local/drizzle/0000_*.sql` | Fresh migration | -| `apps/local/src/server/executor.ts` | Local executor bootstrap with `migrate()` | diff --git a/notes/openapi-add-source-api-cleanup.md b/notes/openapi-add-source-api-cleanup.md deleted file mode 100644 index 28871803f..000000000 --- a/notes/openapi-add-source-api-cleanup.md +++ /dev/null @@ -1,316 +0,0 @@ -# OpenAPI Add Source API Cleanup Notes - -Date: 2026-05-17 -Status: planning - -## Summary - -The current OpenAPI add-source API mixes source ownership, source shape, and -credential binding values in one payload. That makes the common org-shared -source with per-user credentials hard to reason about. We want the immediate -cleanup to make the OpenAPI add endpoint explicit and remove duplicated scope -fields, without solving the larger per-user source/tool discovery model yet. - -The product model for this pass is: - -- OpenAPI source rows and tool definitions are shared at the org/workspace - level in the web UI. -- Runtime credential values can vary by user or org, but they must match the - shared source credential shape. -- Spec fetch credentials are source-definition credentials. If the shared spec - URL requires auth, those credentials are part of maintaining the shared tool - surface. -- Different auth structures should be different source instances, not - different per-user modes inside one source. - -## Current Problems - -`POST /scopes/:scopeId/openapi/specs` currently accepts `targetScope` in the -payload, duplicating the URL `scopeId` for web usage. The endpoint handler maps -`payload.targetScope` to SDK `scope`. - -The payload also accepts `credentialTargetScope`, which is a fallback for direct -secret inputs that do not carry their own binding scope. This is especially -visible in `specFetchCredentials`, where the UI currently serializes direct -secret refs without per-entry scope. - -`spec` is a plain string that can mean either: - -- an HTTP URL to fetch, or -- raw OpenAPI JSON/YAML text. - -`name`, `namespace`, and `baseUrl` are optional in the SDK because they can be -derived from the spec. That is convenient for internal/programmatic callers, but -the web/API boundary already has explicit user-facing values after preview. - -## Immediate Endpoint Direction - -For the HTTP/web add endpoint: - -```txt -POST /scopes/:scopeId/openapi/specs -``` - -`scopeId` should be the source owner scope. Remove payload `targetScope`. - -Make the identity and request base explicit: - -```ts -{ - spec: OpenApiSpecInput; - name: string; - namespace: string; - baseUrl: string; - // source shape fields... -} -``` - -Use a discriminated spec input instead of a string that guesses: - -```ts -type OpenApiSpecInput = { kind: "url"; url: string } | { kind: "blob"; value: string }; -``` - -Remove `credentialTargetScope` from the HTTP payload. Scope should always be -explicit anywhere a concrete secret or connection value is written. - -Keep SDK-level `scope` on `OpenApiSpecConfig`; non-HTTP callers still need to -choose where a source is added. - -## Source Shape vs Values - -The source shape is shared. Values can be bound per scope. - -Source shape examples: - -```ts -headers: { - Authorization: { - kind: "secret", - prefix: "Bearer ", - }, -}, -queryParams: { - api_version: "2026-05-17", -} -``` - -This declares: - -- every user of the source uses an `Authorization` header with a bearer prefix; -- the concrete token value is supplied separately; -- `api_version` is a shared literal source config value. - -Under the hood, secret-shaped headers/query params still normalize to internal -source bindings: - -```ts -headers: { - Authorization: { - kind: "binding", - slot: "header:authorization", - prefix: "Bearer ", - }, -} -``` - -Plain text values are not credentials. They can stay directly in source config. - -Concrete values should be set through source value binding APIs. The existing -low-level operation is `setSourceBinding`; a later cleanup can add domain-level -wrappers such as `setHeaderValue` or `setQueryParamValue`. - -## Example: Org Default With One User Override - -```ts -const orgScope = ScopeId.make("org_123"); -const aliceScope = ScopeId.make("user-org:alice:org_123"); - -const source = await executor.openapi.addSpec({ - scope: orgScope, - spec: { - kind: "url", - url: "https://api.example.com/openapi.json", - }, - name: "Example API", - namespace: "example", - baseUrl: "https://api.example.com", - headers: { - Authorization: { - kind: "secret", - prefix: "Bearer ", - }, - }, - queryParams: { - api_version: "2026-05-17", - }, -}); - -await executor.secrets.set({ - id: SecretId.make("example-org-token"), - scope: orgScope, - name: "Example org token", - value: process.env.EXAMPLE_ORG_TOKEN!, -}); - -await executor.openapi.setHeaderValue({ - source, - scope: orgScope, - name: "Authorization", - value: { - kind: "secret", - secretId: SecretId.make("example-org-token"), - secretScope: orgScope, - }, -}); - -await executor.secrets.set({ - id: SecretId.make("alice-example-token"), - scope: aliceScope, - name: "Alice Example token", - value: process.env.ALICE_EXAMPLE_TOKEN!, -}); - -await executor.openapi.setHeaderValue({ - source, - scope: aliceScope, - name: "Authorization", - value: { - kind: "secret", - secretId: SecretId.make("alice-example-token"), - secretScope: aliceScope, - }, -}); -``` - -Resolution: - -- Alice gets `Authorization: Bearer `. -- Other users fall back to `Authorization: Bearer `. -- The source/tool definition remains org-scoped and shared. - -## OAuth Shape - -OAuth follows the same split. The source declares shared OAuth metadata: - -- security scheme name; -- flow; -- authorization/token URLs; -- scopes; -- where client id/client secret/connection values are expected. - -Then values are bound separately: - -- OAuth app client id/secret can be org-level values. -- OAuth access/refresh token connection is commonly per user. - -Conceptually: - -```ts -const source = await executor.openapi.addSpec({ - scope: orgScope, - spec: { kind: "url", url: "https://api.example.com/openapi.json" }, - name: "Example API", - namespace: "example", - baseUrl: "https://api.example.com", - oauth2: { - securitySchemeName: "oauth2", - flow: "authorizationCode", - authorizationUrl: "https://api.example.com/oauth/authorize", - tokenUrl: "https://api.example.com/oauth/token", - scopes: ["read", "write"], - }, -}); - -await executor.openapi.setOAuthClientCredentials({ - source, - scope: orgScope, - clientId: { kind: "secret", secretId: "client-id", secretScope: orgScope }, - clientSecret: { kind: "secret", secretId: "client-secret", secretScope: orgScope }, -}); - -await executor.openapi.setOAuthConnection({ - source, - scope: aliceScope, - connectionId: aliceConnection.id, -}); -``` - -The current implementation can continue using internal OAuth slots. The public -SDK does not need to expose those slots for the simple flow. - -## Spec Fetch Credentials - -Spec fetch credentials differ from runtime credentials because they may be -needed before the source exists. - -For this immediate pass: - -- keep spec fetch credentials on `addSpec`; -- treat them as source-definition credentials; -- make the UI use the same secret scope selection semantics as other secret - inputs; -- remove `credentialTargetScope` by making any concrete spec fetch secret refs - carry explicit scope information. - -Longer term, if we want all persisted values to use bindings only, preview may -need either request-only credentials or callers must fetch private specs -themselves and pass `{ kind: "blob", value }`. - -## Sharing Boundary - -One source has one shared credential structure. Users can provide different -values for that structure, but they cannot change the structure per user. - -Supported: - -```txt -Source shape: Authorization header with Bearer prefix -Org value: shared token -Alice value: Alice token override -Bob value: Bob token override -``` - -Not supported as one source: - -```txt -Alice uses Authorization: Bearer -Bob uses X-API-Key: -Carol uses OAuth -``` - -Those are different source instances, even if they point at the same OpenAPI -spec and base URL. - -## MCP and Per-User Tool Discovery - -MCP exposes a larger issue: some servers may return different tools or tool -descriptions based on the authenticated user. That means auth can affect -discovery, not just invocation. - -This does not need to be solved in the OpenAPI add-source cleanup. Defer: - -- per-user source rows; -- per-user tool discovery; -- MCP auth-dependent tool descriptions; -- template/materialization flows for user-bound discovery sources. - -For now, keep OpenAPI on the stable shared tool surface model. - -## Deferred API Cleanup - -Leave `setSourceBinding` in place for edit/override flows for now. It is a -lower-level source value binding API and already supports text, secret, and -connection values. - -A later public SDK cleanup can add domain-level wrappers: - -- `setHeaderValue`; -- `setQueryParamValue`; -- `setSpecFetchHeaderValue`; -- `setSpecFetchQueryParamValue`; -- `setOAuthClientCredentials`; -- `setOAuthConnection`. - -Those wrappers can map to internal binding slots without exposing slots to the -consumer-facing simple flow. diff --git a/notes/openapi-sdk-credential-configuration.md b/notes/openapi-sdk-credential-configuration.md deleted file mode 100644 index baf8ceccf..000000000 --- a/notes/openapi-sdk-credential-configuration.md +++ /dev/null @@ -1,509 +0,0 @@ -# OpenAPI SDK Credential Configuration Notes - -Date: 2026-05-17 -Status: planning - -## Summary - -The SDK should present OpenAPI source onboarding as two explicit phases: - -1. Add/import a shared source shape. -2. Configure scoped credential values for that source. - -The internal implementation can still use source slots and scoped bindings, -but normal SDK callers should not need to think about `setSourceBinding`, -`connectionSlot`, `clientIdSlot`, `credentialTargetScope`, or plugin-specific -binding row shapes. - -The product flow we want to preserve is: - -```txt -Admin adds Stripe/Linear/GitHub once at org scope. -Org may set default credentials. -Each user can sign in or provide their own token. -The source and tools stay shared. -Credentials remain scoped and overridable. -``` - -## Core Vocabulary - -**Source shape** is the shared definition: - -- OpenAPI spec and extracted operations. -- Base URL. -- Declared headers and query params the source knows how to send. -- Selected OAuth method, if any. -- OAuth flow metadata such as authorization URL, token URL, and scopes. - -**Scoped credential values** are the concrete values attached later: - -- Bearer/API key token values. -- Header/query param text values. -- OAuth client ID and client secret. -- OAuth connection IDs. -- Spec-fetch credential values. - -Source shape belongs to the source owner scope. Credential values belong to -the scope where they are configured and can inherit/override through the scope -stack. - -## Public SDK Shape - -The common bearer token flow should read like this: - -```ts -const org = ScopeId.make("org_acme"); -const user = ScopeId.make("user_rhys"); - -const source = await executor.openapi.addSpec({ - scope: org, - name: "Stripe", - namespace: "stripe", - baseUrl: "https://api.stripe.com", - spec: { - kind: "url", - url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", - }, - - headers: { - Authorization: { - prefix: "Bearer ", - }, - }, -}); - -await executor.openapi.configure(source, { - scope: user, - headers: { - Authorization: SecretId.make("stripe_api_key"), - }, -}); -``` - -The caller should not pass: - -```ts -kind: "binding"; -slot: "header:authorization"; -credentialTargetScope: user; -``` - -Those are internal implementation details. - -## Strict Configure Semantics - -`configure` fills declared holes in an existing source shape. It should not -create new source shape. - -If the source was imported with: - -```ts -headers: { - Authorization: { - prefix: "Bearer ", - }, -} -``` - -then this is valid: - -```ts -await executor.openapi.configure(source, { - scope: user, - headers: { - Authorization: SecretId.make("stripe_api_key"), - }, -}); -``` - -This should fail: - -```ts -await executor.openapi.configure(source, { - scope: user, - headers: { - Username: SecretId.make("stripe_api_key"), - }, -}); -``` - -because `Username` was not declared on the source. Allowing this would silently -write a binding for a value the source never sends. - -Suggested error: - -```txt -Unknown header "Username" for OpenAPI source "stripe". -Declared headers: Authorization. -``` - -The same rule applies to query params: - -```ts -await executor.openapi.configure(source, { - scope: user, - queryParams: { - api_key: SecretId.make("example_api_key"), - }, -}); -``` - -should only succeed if `api_key` was declared in the source shape. - -Adding or changing source shape should be a different operation: - -```ts -await executor.openapi.updateSource(source, { - headers: { - Authorization: { prefix: "Bearer " }, - Username: {}, - }, -}); - -await executor.openapi.configure(source, { - scope: user, - headers: { - Username: SecretId.make("basic_username"), - }, -}); -``` - -## Values Accepted By Configure - -`configure` should accept ergonomic value inputs and normalize them to binding -values internally. - -```ts -type OpenApiConfiguredCredentialValue = - | string - | SecretId - | { - kind: "secret"; - secretId: SecretId; - secretScope?: ScopeId; - } - | { - kind: "text"; - text: string; - }; -``` - -Examples: - -```ts -await executor.openapi.configure(source, { - scope: user, - headers: { - Authorization: SecretId.make("stripe_api_key"), - "X-Workspace": "acme", - }, - queryParams: { - version: "2026-05-17", - }, -}); -``` - -Internal binding writes: - -```ts -setBinding({ - source: { pluginId: "openapi", id: "stripe", scope: org }, - scope: user, - slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("stripe_api_key") }, -}); - -setBinding({ - source: { pluginId: "openapi", id: "stripe", scope: org }, - scope: user, - slot: "header:x-workspace", - value: { kind: "text", text: "acme" }, -}); - -setBinding({ - source: { pluginId: "openapi", id: "stripe", scope: org }, - scope: user, - slot: "query_param:version", - value: { kind: "text", text: "2026-05-17" }, -}); -``` - -## Org Default With User Override - -```ts -const source = await executor.openapi.addSpec({ - scope: org, - name: "Example API", - namespace: "example", - baseUrl: "https://api.example.com", - spec: { - kind: "url", - url: "https://api.example.com/openapi.json", - }, - headers: { - Authorization: { - prefix: "Bearer ", - }, - }, -}); - -await executor.openapi.configure(source, { - scope: org, - headers: { - Authorization: SecretId.make("example_org_token"), - }, -}); - -await executor.openapi.configure(source, { - scope: user, - headers: { - Authorization: SecretId.make("example_user_token"), - }, -}); -``` - -Resolution: - -```txt -User with user binding: - Authorization: Bearer - -User without user binding: - Authorization: Bearer -``` - -The source and tools remain org-scoped. Only values vary by scope. - -## OAuth Is Not Just A Header - -OAuth eventually produces an `Authorization` header, but the setup is not just -"set the Authorization header value." OAuth has distinct source shape and -credential value concerns: - -- The selected OpenAPI security scheme. -- OAuth flow. -- Authorization URL. -- Token URL. -- Issuer URL when relevant. -- Scopes. -- Client ID. -- Client secret. -- Connection ID. -- Token refresh lifecycle. - -The source should choose one OAuth configuration at import time. After that, -credential operations should not ask the caller to repeat the security scheme -name in the common case. - -## Current OpenAPI OAuth Model - -Today preview returns `oauth2Presets`, one per supported OAuth option derived -from the spec. The add UI chooses one preset and persists one -`OAuth2SourceConfig` on the source: - -```ts -oauth2: { - kind: "oauth2", - securitySchemeName: selectedOAuth2Preset.securitySchemeName, - flow: selectedOAuth2Preset.flow, - authorizationUrl: selectedOAuth2Preset.authorizationUrl, - tokenUrl: selectedOAuth2Preset.tokenUrl, - issuerUrl: selectedOAuth2Preset.issuerUrl ?? null, - clientIdSlot: oauth2ClientIdSlot(selectedOAuth2Preset.securitySchemeName), - clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName), - connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName), - scopes: [...oauth2SelectedScopes], -} -``` - -Client ID and client secret are stored as scoped bindings: - -```ts -setBinding({ - slot: oauth2.clientIdSlot, - scope: org, - value: { - kind: "secret", - secretId: SecretId.make("linear_client_id"), - }, -}); - -setBinding({ - slot: oauth2.clientSecretSlot, - scope: org, - value: { - kind: "secret", - secretId: SecretId.make("linear_client_secret"), - }, -}); -``` - -The user connection is also stored as a scoped binding: - -```ts -setBinding({ - slot: oauth2.connectionSlot, - scope: user, - value: { - kind: "connection", - connectionId: ConnectionId.make("linear_user_connection"), - }, -}); -``` - -That underlying model is sound. The clunky part is exposing the slot names and -raw binding operations as the normal SDK path. - -## Proposed OAuth SDK Flow - -If the spec has exactly one supported OAuth option, `addSpec` can infer it. If -there are multiple supported OAuth options, `addSpec` should require an -import-time selection. - -Single OAuth option: - -```ts -const source = await executor.openapi.addSpec({ - scope: org, - name: "Linear", - namespace: "linear", - baseUrl: "https://api.linear.app", - spec: { - kind: "url", - url: "https://example.com/linear-openapi.json", - }, - oauth: true, -}); -``` - -Multiple OAuth options: - -```ts -const source = await executor.openapi.addSpec({ - scope: org, - name: "Linear", - namespace: "linear", - baseUrl: "https://api.linear.app", - spec: { - kind: "url", - url: "https://example.com/linear-openapi.json", - }, - oauth: { - securityScheme: "linearOAuth", - }, -}); -``` - -Configure app credentials: - -```ts -await executor.openapi.configure(source, { - scope: org, - oauth: { - clientId: SecretId.make("linear_client_id"), - clientSecret: SecretId.make("linear_client_secret"), - }, -}); -``` - -Connect a user: - -```ts -await executor.openapi.connect(source, { - scope: user, -}); -``` - -Client credentials flow should use the same public concepts: - -```ts -await executor.openapi.configure(source, { - scope: org, - oauth: { - clientId: SecretId.make("service_client_id"), - clientSecret: SecretId.make("service_client_secret"), - }, -}); - -await executor.openapi.connect(source, { - scope: org, -}); -``` - -Internally this still writes: - -```txt -oauth2::client-id -oauth2::client-secret -oauth2::connection -``` - -but SDK callers do not see those slots. - -## OAuth Validation - -`configure(source, { oauth: ... })` should fail if the source has no selected -OAuth config: - -```txt -OpenAPI source "stripe" does not declare OAuth credentials. -``` - -`connect(source, { scope })` should fail if the source has no selected OAuth -config: - -```txt -OpenAPI source "stripe" does not support OAuth connect. -``` - -`connect` should validate required client credentials before starting OAuth: - -```txt -Client ID must be configured before connecting. -Client secret must be configured before connecting. -``` - -For authorization-code flows, client secret may be optional for public PKCE -clients. For client-credentials flows, client secret is required. - -## Configure Should Be A Facade - -The public OpenAPI SDK can expose: - -```ts -executor.openapi.addSpec(...) -executor.openapi.configure(...) -executor.openapi.connect(...) -``` - -The lower-level core API can still expose generic bindings for advanced or UI -infrastructure cases: - -```ts -executor.sources.setBinding(...) -executor.sources.listBindings(...) -executor.sources.removeBinding(...) -executor.sources.getSlotManifest(...) -``` - -But the common OpenAPI SDK path should not require users to manually map: - -```txt -Authorization -> header:authorization -OAuth client ID -> oauth2::client-id -OAuth connection -> oauth2::connection -``` - -The SDK should derive that mapping from the selected source shape. - -## Design Rule - -`addSpec` defines what this source can use. - -`configure` assigns values to things this source already declared. - -`connect` creates or refreshes OAuth connection values for the selected OAuth -configuration. - -Bindings and slots are the internal storage/resolution mechanism behind those -operations. diff --git a/notes/plugin-derived-source-configure.md b/notes/plugin-derived-source-configure.md deleted file mode 100644 index a5afd8a4a..000000000 --- a/notes/plugin-derived-source-configure.md +++ /dev/null @@ -1,356 +0,0 @@ -# Plugin-Derived Source Configure Notes - -Date: 2026-05-17 -Status: planning - -## Summary - -`executor.sources.configure(...)` can exist without making headers, query -params, OAuth, database passwords, CLI env vars, or any other protocol-specific -concept part of core. - -The key distinction: - -- Core owns source identity, scoped binding storage, resolution, validation, and - dispatch. -- Plugins own their configure schemas and the translation from configure input - to core bindings. -- Shared UI lives in reusable plugin-family components, not in core source - semantics. - -This means `executor.openapi.configure(...)` and -`executor.graphql.configure(...)` can remain the typed plugin-native APIs, while -`executor.sources.configure(...)` becomes a generic dispatcher derived from the -installed plugin implementations. - -## Why Not Put Headers In Core - -Core needs to support many source types: - -- OpenAPI and GraphQL over HTTP. -- MCP over HTTP or stdio. -- Databases. -- CLIs. -- Future source types we have not named yet. - -Only some of these have request headers, query params, or OAuth. A database -source may have a connection string, username, password, TLS certs, and schema -selection. A CLI source may have env vars, argv templates, working directory, -and stdin. If core's public configure model says `headers` and `query`, it is -quietly HTTP-specific. - -Core's portable primitive is still: - -```ts -{ - source: { id, scope }, - scope, - slot, - value, -} -``` - -where the owning plugin defines what `slot` means. - -## Desired API Shape - -Plugin APIs remain first-class: - -```ts -await executor.openapi.configure(source, { - scope: user, - request: { - headers: { - Authorization: SecretId.make("stripe_api_key"), - }, - }, -}); -``` - -```ts -await executor.graphql.configure(source, { - scope: user, - request: { - headers: { - Authorization: SecretId.make("github_token"), - }, - }, -}); -``` - -The generic source API is derived from those plugin implementations: - -```ts -await executor.sources.configure(source, { - type: "openapi", - scope: user, - request: { - headers: { - Authorization: SecretId.make("stripe_api_key"), - }, - }, -}); -``` - -The `type` discriminant is for dispatch and type narrowing. The actual source -record should still be checked so callers cannot configure an OpenAPI source -with a GraphQL payload. - -```ts -const storedSource = yield * sources.get(source); - -if (storedSource.type !== input.type) { - return ( - yield * - Effect.fail( - new SourceTypeMismatch({ - source, - expected: storedSource.type, - received: input.type, - }), - ) - ); -} -``` - -## Registration Model - -Each plugin registers its configure implementation with core: - -```ts -openapiPlugin.registerSourceConfigure({ - type: "openapi", - schema: OpenApiConfigureInput, - configure: openApiConfigure, -}); -``` - -```ts -graphqlPlugin.registerSourceConfigure({ - type: "graphql", - schema: GraphqlConfigureInput, - configure: graphqlConfigure, -}); -``` - -Core dispatch stays small: - -```ts -const configure = (source, input) => - Effect.gen(function* () { - const storedSource = yield* Sources.get(source); - const implementation = yield* SourceConfigureRegistry.get(input.type); - - if (storedSource.type !== input.type) { - return yield* Effect.fail( - new SourceTypeMismatch({ - source, - expected: storedSource.type, - received: input.type, - }), - ); - } - - const parsed = yield* Schema.decodeUnknown(implementation.schema)(input); - - return yield* implementation.configure(source, parsed); - }); -``` - -The plugin implementation compiles its domain-specific configure input into -core binding operations: - -```ts -const openApiConfigure = (source, input) => - Effect.gen(function* () { - const bindings = yield* compileOpenApiConfigureBindings(source, input); - - yield* Sources.replaceBindings({ - source, - scope: input.scope, - bindings, - }); - }); -``` - -## Shared HTTP Credential Pieces - -OpenAPI, GraphQL, and HTTP MCP should reuse HTTP credential vocabulary, but -that vocabulary should live in a shared protocol helper, not core. - -```ts -type HttpRequestCredentialConfig = { - headers?: Record; - query?: Record; - oauth?: OAuthCredentialConfig; -}; -``` - -Then plugins embed that helper where it makes sense: - -```ts -type OpenApiConfigureInput = { - type: "openapi"; - scope: ScopeId; - request?: HttpRequestCredentialConfig; - specFetch?: HttpRequestCredentialConfig; -}; -``` - -```ts -type GraphqlConfigureInput = { - type: "graphql"; - scope: ScopeId; - request?: HttpRequestCredentialConfig; - introspection?: HttpRequestCredentialConfig; -}; -``` - -```ts -type HttpMcpConfigureInput = { - type: "mcp"; - scope: ScopeId; - request?: HttpRequestCredentialConfig; -}; -``` - -Database and CLI plugins should not see this shape unless they opt into it. - -## Shared UI Direction - -The generic UI should call one mutation: - -```ts -configureSource(source, input); -``` - -but forms should be plugin-specific or plugin-family-specific. - -HTTP-ish plugins can share components: - -```tsx - setConfig({ ...config, request })} -/> -``` - -OpenAPI can use it twice: - -```tsx - - -``` - -GraphQL can use it for request and introspection credentials: - -```tsx - - -``` - -MCP HTTP can use it for its request transport credentials: - -```tsx - -``` - -Other source families get their own shared components: - -```tsx - - -``` - -The reuse boundary is therefore explicit: - -- Core mutation and atoms are shared. -- Plugin configure schemas are plugin-owned. -- HTTP credential UI is shared only by plugins that use HTTP credential shapes. -- Database and CLI UI are not forced through HTTP concepts. - -## OAuth Notes - -OAuth should be part of the HTTP credential helper, but it should not be modeled -as just another header value. OAuth configuration needs room for: - -- Authorization URL. -- Token URL. -- Client ID. -- Client secret. -- Scopes. -- PKCE and auth-code state. -- Refresh behavior. -- Token placement after exchange. - -The resulting access token may be placed into a header or query param, but the -configuration and lifecycle are richer than a raw `Authorization` binding. - -Avoid double nesting like: - -```ts -oauth2: { - oauth2: { - ... - }, -} -``` - -Prefer a single OAuth object embedded at the credential boundary: - -```ts -request: { - oauth: { - clientId: SecretId.make("client_id"), - clientSecret: SecretId.make("client_secret"), - authorizationUrl: "https://example.com/oauth/authorize", - tokenUrl: "https://example.com/oauth/token", - scopes: ["read", "write"], - placement: { - header: "Authorization", - scheme: "Bearer", - }, - }, -} -``` - -## MCP And Auth-Dependent Metadata - -MCP can expose different tool descriptions depending on auth state. GraphQL can -also theoretically expose different introspection results by credential scope. - -This does not invalidate the shared configure dispatch model, but it means the -plugin must decide whether metadata is global source shape or scoped resolved -shape. - -For MCP especially, avoid assuming a single globally cached tool manifest per -source forever. A future MCP implementation may need: - -- shared source transport config; -- scoped credential bindings; -- scoped or credential-derived tool metadata cache. - -That is plugin behavior, not core binding behavior. - -## Design Guardrails - -- Do not add `headers`, `query`, or `oauth` as universal core source fields. -- Do keep `executor.openapi.configure` and other plugin-native configure APIs. -- Do derive `executor.sources.configure` from registered plugin implementations. -- Do validate that `input.type` matches the stored source type. -- Do compile plugin configure input into core binding writes internally. -- Do build shared React components around explicit plugin-family shapes such as - HTTP request credentials. -- Do not require database, CLI, or future non-HTTP sources to fit the HTTP - credential model. - -## Likely Implementation Order - -1. Keep core binding APIs protocol-agnostic. -2. Make OpenAPI's configure implementation compile to those core bindings. -3. Introduce source-level configure dispatch over registered plugin configure - implementations. -4. Move OpenAPI UI to the generic configure mutation while keeping OpenAPI's - form domain-specific. -5. Extract shared HTTP credential config types and UI components when GraphQL or - MCP is ported, rather than abstracting from OpenAPI alone. diff --git a/notes/plugin-source-configuration-overhaul.md b/notes/plugin-source-configuration-overhaul.md deleted file mode 100644 index 2c8312270..000000000 --- a/notes/plugin-source-configuration-overhaul.md +++ /dev/null @@ -1,729 +0,0 @@ -# Plugin Source Configuration Overhaul - -Date: 2026-05-18 -Status: implemented in progress - -## Goal - -Turn the source credential/configuration work into a full plugin overhaul instead -of an incremental OpenAPI cleanup. - -Implementation goal for this PR: - -```txt -PR #844 should become the complete source-configuration overhaul for first-party -source plugins. OpenAPI, GraphQL, and MCP should all move onto plugin-derived -configure APIs, shared core credential bindings, shared HTTP credential helpers -where applicable, JSON-backed plugin source config, and composed shared React -credential UI. The old plugin-specific binding wrappers, endpoints, stores, -credential child tables, and duplicated UI atoms should be removed rather than -kept as compatibility shims. -``` - -By the end, OpenAPI, GraphQL, and MCP should share the same underlying source -configuration architecture: - -- Core owns generic source identity, scoped credential bindings, source - configure dispatch, and validation. -- Plugins own their source-specific configure schemas and source config - decoding. -- HTTP-ish plugins share HTTP credential config, runtime helpers, and React - components through a shared HTTP package. -- Plugin-private storage remains an overridable plugin facility, backed by one - shared plugin storage table rather than plugin-specific SQL tables. -- Duplicated OpenAPI/GraphQL/MCP binding endpoints, stores, React atoms, and - child tables should be deleted aggressively. - -The desired result is that a future improvement to HTTP credential handling -such as adding a plaintext header path, changing the secret input UI, or -improving OAuth flows benefits OpenAPI, GraphQL, and MCP HTTP rather than being -reimplemented per plugin. - -## Settled Decisions - -- This can be one large PR/overhaul, not a series of small incremental PRs. -- `executor.sources.configure(...)` should be implemented in this overhaul. -- Plugin-native configure APIs should remain: - - `executor.openapi.configure(...)` - - `executor.graphql.configure(...)` - - `executor.mcp.configure(...)` -- Low-level binding APIs should be hidden from normal consumers where possible. - They may remain exported for internal/advanced use, but product and SDK - happy paths should lead with configure. -- Source config may move to JSON. Use Effect Schema at boundaries. -- Migrations are one-shot migrations. Preserve existing source configuration and - credential values. -- Persist source config; derive slot manifests from source config. -- Add a shared HTTP package, likely `@executor-js/plugin-http-source`. -- MCP configure should be transport-discriminated. -- Shared UI components can omit MCP stdio for this pass. -- Plaintext credential values are not needed unless a current flow already has - them. -- OAuth should be designed for the long-term model, not as a short-term header - hack. -- UI should be hand-composed from reusable components, not generated entirely - from manifests. -- GraphQL can use the same request URL/credential config for introspection for - now. -- MCP may require auth at add time if auth is needed to list tools. -- Naming like `request`, `specFetch`, `introspection`, `transport`, `env`, and - `query` is acceptable. - -## Storage Model - -Do not kill plugin storage. - -The overhaul should separate three storage concerns that are currently blurred: - -1. First-class Executor source records. -2. Scoped credential values. -3. Plugin-private state/cache/documents. - -### First-Class Source Records - -Sources are product entities. Core needs to list them, scope them, remove them, -refresh them, attach tools/policies to them, and expose them through generic -source APIs. - -Source records should remain first-class, but plugin-specific source details can -move into plugin-owned JSON config decoded by Effect Schema. - -For example: - -```txt -openapi_source - id - scope_id - name - config_json -``` - -```txt -graphql_source - id - scope_id - name - config_json -``` - -```txt -mcp_source - id - scope_id - name - config_json -``` - -The exact physical table shape can follow the repo's current storage layout, but -the duplicated credential child tables should go away where they only represent -source config and binding placeholders. - -Plugin packages own their config schemas: - -```ts -export const OpenApiStoredSourceConfig = Schema.Struct({ - request: Schema.optional(HttpRequestSourceConfig), - specFetch: Schema.optional(HttpRequestSourceConfig), - // OpenAPI-specific spec/base URL/operation details... -}); -``` - -```ts -export const GraphqlStoredSourceConfig = Schema.Struct({ - request: Schema.optional(HttpRequestSourceConfig), - // GraphQL-specific endpoint/schema details... -}); -``` - -```ts -export const McpStoredSourceConfig = Schema.Union( - Schema.Struct({ - transport: Schema.Literal("http"), - request: Schema.optional(HttpRequestSourceConfig), - // MCP HTTP details... - }), - Schema.Struct({ - transport: Schema.Literal("stdio"), - command: Schema.String, - args: Schema.Array(Schema.String), - env: Schema.Record({ key: Schema.String, value: ProcessEnvSourceConfig }), - }), -); -``` - -### Credential Bindings - -Core `credential_binding` remains the only place scoped credential values live. - -```txt -credential_binding - plugin_id - source_id - source_scope_id - scope_id - slot_key - kind - secret_id - secret_scope_id - connection_id - text_value - created_at - updated_at -``` - -Source config declares slots and their wire-format meaning. Credential bindings -store scoped values for those slots. - -Example OpenAPI source config: - -```ts -{ - request: { - headers: { - Authorization: { - slotKey: "request.headers.authorization", - prefix: "Bearer ", - }, - }, - query: {}, - }, - specFetch: { - headers: {}, - query: {}, - }, -} -``` - -Example value: - -```txt -credential_binding - plugin_id = "openapi" - source_id = "stripe" - source_scope_id = "org_acme" - scope_id = "user_rhys" - slot_key = "request.headers.authorization" - kind = "secret" - secret_id = "stripe_api_key" -``` - -### Plugin-Private Storage - -Plugin storage should remain as an overridable facility. - -The target is one shared table for all plugin-private storage, partitioned by -plugin and collection: - -```txt -plugin_storage - plugin_id - collection - scope_id - key - data_json - created_at - updated_at -``` - -Primary key: - -```txt -(plugin_id, collection, scope_id, key) -``` - -This is for plugin-owned private state/cache/documents: - -- OAuth state caches. -- Remote metadata caches. -- Probe results. -- Background job state. -- Plugin-specific documents that are not first-class Executor sources. - -The plugin storage API should remain overridable when constructing the executor -or installing/creating a plugin. This is separate from source storage and -credential binding storage. - -Conceptually: - -```ts -createExecutor({ - pluginStorage: myPluginStorageProvider, -}); -``` - -or, if plugin-level override is already the local pattern: - -```ts -openApiPlugin({ - storage: myPluginStorageProvider, -}); -``` - -The exact wiring should preserve the repo's existing ability for users to -override plugin storage. The overhaul should consolidate plugin-private storage -tables, not remove plugin storage. - -### What We Are Deleting - -Delete plugin-specific storage that only duplicates source config or -credential-binding behavior. - -Likely delete/replace: - -- OpenAPI source binding wrappers and backing adapters. -- OpenAPI header/query/spec-fetch child tables that only store slot config. -- GraphQL header/query child tables. -- MCP header/query child tables. -- Plugin-specific binding resolvers/listers/validators. -- Plugin-specific HTTP binding endpoints. -- Plugin-specific React binding atoms. - -Do not delete: - -- First-class source records. -- Core `credential_binding`. -- Generic plugin-private storage. -- The ability to provide/override plugin storage. - -## Configure API Model - -Core gets a plugin-derived configure dispatch: - -```ts -await executor.sources.configure(source, { - type: "openapi", - scope: user, - request: { - headers: { - Authorization: SecretId.make("stripe_api_key"), - }, - }, -}); -``` - -Plugin-native APIs remain: - -```ts -await executor.openapi.configure(source, { - scope: user, - request: { - headers: { - Authorization: SecretId.make("stripe_api_key"), - }, - }, -}); -``` - -```ts -await executor.graphql.configure(source, { - scope: user, - request: { - headers: { - Authorization: SecretId.make("github_token"), - }, - }, -}); -``` - -```ts -await executor.mcp.configure(source, { - scope: user, - transport: "http", - request: { - headers: { - Authorization: SecretId.make("mcp_token"), - }, - }, -}); -``` - -MCP stdio is transport-specific and should not be forced through HTTP helpers: - -```ts -await executor.mcp.configure(source, { - scope: user, - transport: "stdio", - env: { - GITHUB_TOKEN: SecretId.make("github_token"), - }, -}); -``` - -Core dispatch: - -```ts -const configure = (source, input) => - Effect.gen(function* () { - const storedSource = yield* Sources.get(source); - const implementation = yield* SourceConfigureRegistry.get(input.type); - - if (storedSource.type !== input.type) { - return yield* new SourceTypeMismatch({ - source, - expected: storedSource.type, - received: input.type, - }); - } - - const parsed = yield* Schema.decodeUnknown(implementation.schema)(input); - - return yield* implementation.configure(source, parsed); - }); -``` - -Plugin registration: - -```ts -openapiPlugin.registerSourceConfigure({ - type: "openapi", - schema: OpenApiConfigureInput, - configure: openApiConfigure, - manifest: deriveOpenApiCredentialManifest, -}); -``` - -The plugin configure implementation compiles plugin input into core binding -operations: - -```ts -const openApiConfigure = (source, input) => - Effect.gen(function* () { - const bindings = yield* compileOpenApiConfigureBindings(source, input); - - yield* Sources.replaceBindings({ - source, - scope: input.scope, - slotPrefixes: ["request.", "specFetch."], - bindings, - }); - }); -``` - -## Shared HTTP Package - -Create a shared package for HTTP source helpers, likely: - -```txt -packages/plugins/http-source -``` - -Package name: - -```txt -@executor-js/plugin-http-source -``` - -This package is not core. It exists because OpenAPI, GraphQL, and MCP HTTP share -HTTP credential concepts while databases, CLIs, and MCP stdio do not. - -It should own: - -- HTTP credential config types. -- Header/query slot key helpers. -- OAuth config types. -- Binding compiler helpers. -- Runtime resolution helpers. -- Helpers that apply resolved credentials to HTTP requests. -- Slot manifest helpers. -- React credential components, either directly or via a `/react` subpath export. - -Possible layout: - -```txt -packages/plugins/http-source - src/ - sdk/ - types.ts - slots.ts - configure.ts - resolve.ts - oauth.ts - react/ - HttpCredentialsProvider.tsx - HttpHeaderCredentials.tsx - HttpQueryCredentials.tsx - OAuthCredentials.tsx - index.ts -``` - -Example shared config: - -```ts -type HttpRequestSourceConfig = { - headers?: Record; - query?: Record; - oauth?: HttpOAuthSourceConfig; -}; -``` - -Example configure input: - -```ts -type HttpRequestConfigureInput = { - headers?: Record; - query?: Record; - oauth?: HttpOAuthConfigureInput; -}; -``` - -OpenAPI can embed the shared shape twice: - -```ts -type OpenApiConfigureInput = { - type: "openapi"; - scope: ScopeId; - request?: HttpRequestConfigureInput; - specFetch?: HttpRequestConfigureInput; -}; -``` - -GraphQL can embed it once for now: - -```ts -type GraphqlConfigureInput = { - type: "graphql"; - scope: ScopeId; - request?: HttpRequestConfigureInput; -}; -``` - -MCP HTTP can embed it behind a transport discriminant: - -```ts -type McpConfigureInput = - | { - type: "mcp"; - transport: "http"; - scope: ScopeId; - request?: HttpRequestConfigureInput; - } - | { - type: "mcp"; - transport: "stdio"; - scope: ScopeId; - env?: Record; - }; -``` - -## OAuth Direction - -OAuth belongs with HTTP helpers, but it should not be modeled as only a header -value. OAuth configuration needs to support long-term flow requirements: - -- Authorization URL. -- Token URL. -- Client ID. -- Client secret. -- Scopes. -- PKCE/auth-code state. -- Refresh behavior. -- Resulting token placement. - -## Implementation Notes - -This branch implements the core shape described above: - -- `executor.sources.configure(...)` dispatches through the owning plugin's - registered `sourceConfigure` implementation. -- `executor.openapi.configure(...)`, `executor.graphql.configure(...)`, and - `executor.mcp.configure(...)` remain plugin-native entry points. -- OpenAPI, GraphQL, and MCP source/operation/plugin rows now use the shared - `plugin_storage` table instead of plugin-specific SQL source/operation tables. -- Core `credential_binding` remains the shared source credential value store. -- GraphQL and MCP no longer expose plugin-specific source binding HTTP - endpoints or SDK wrapper methods; React callers use core source credential - binding atoms. -- Local and cloud one-shot migrations copy old plugin source rows into - `plugin_storage` and drop the old plugin-specific source/config tables. -- The shared HTTP source package exists as `@executor-js/plugin-http-source`. - -The React layer still intentionally composes the existing shared credential -components instead of replacing whole source forms with generated UIs. That -keeps MCP stdio out of HTTP-specific components while making header/query/OAuth -credential changes land in shared components used by OpenAPI, GraphQL, and MCP -HTTP. - -- User-scoped connections. - -The resulting access token may be placed in a header or query parameter, but the -OAuth lifecycle is richer than raw header configuration. - -Avoid double nesting: - -```ts -oauth2: { - oauth2: { - // ... - }, -} -``` - -Prefer one OAuth object at the HTTP credential boundary: - -```ts -request: { - oauth: { - clientId: SecretId.make("client_id"), - clientSecret: SecretId.make("client_secret"), - authorizationUrl: "https://example.com/oauth/authorize", - tokenUrl: "https://example.com/oauth/token", - scopes: ["read", "write"], - placement: { - header: "Authorization", - scheme: "Bearer", - }, - }, -} -``` - -## Slot Manifest - -Persist source config, derive manifests from it. - -The manifest is for UI/status/validation. It should not be the source of truth -if it can be derived from plugin-owned config. - -Example derived manifest entry: - -```ts -{ - slotKey: "request.headers.authorization", - label: "Authorization", - family: "http.header", - required: true, - valueKind: "secret", - placement: { - header: "Authorization", - prefix: "Bearer ", - }, -} -``` - -Core may expose generic manifest/query APIs, but it should not interpret -`family: "http.header"` beyond using it as metadata. The HTTP package and UI -components interpret HTTP metadata. - -## React/UI Direction - -Use composition, not a mega-form with many boolean props. - -Avoid: - -```tsx - -``` - -Prefer plugin forms composed from shared sections: - -```tsx - - - - - -``` - -```tsx - - - -``` - -```tsx - - - -``` - -MCP stdio can be omitted from shared HTTP components for now. It should later -compose process/env components instead of forcing itself through the HTTP -credential UI. - -Shared UI pieces likely include: - -- `CredentialValueInput` -- `SecretPicker` -- `ConnectionPicker` -- `HttpHeaderCredentials` -- `HttpQueryCredentials` -- `OAuthCredentials` -- `CredentialStatusList` -- `SourceConfigureProvider` - -The generic UI mutation should call: - -```ts -configureSource(source, input); -``` - -Plugin forms produce typed configure payloads. - -## MCP Notes - -MCP stdio causes code-sharing problems if MCP is treated as one credential -family. The sharing boundary should be transport-level: - -- OpenAPI, GraphQL, and MCP HTTP share HTTP credential helpers. -- MCP stdio and future CLI sources should share process/env helpers later. -- Databases should have their own connection helper family later. - -It is acceptable for MCP add/import to require auth if the server needs auth to -list tools. Longer term, MCP may need scoped/auth-derived tool metadata because -some servers expose different tools/descriptions based on auth state. - -This overhaul does not need to solve scoped MCP metadata fully, but it should -avoid baking in an assumption that one global tool manifest is always correct. - -## Migration Plan - -This is a one-shot migration that preserves existing config and values. - -Migration responsibilities: - -1. Move concrete credential values into `credential_binding` if any remain in - plugin-specific tables/JSON. -2. Move source declaration/config into plugin-owned JSON config. -3. Preserve source IDs, names, scopes, base URLs, specs, endpoints, transports, - and tool relationships. -4. Preserve OAuth connections and client credentials. -5. Drop or ignore old plugin-specific child tables after data is migrated. - -Tests should cover: - -- OpenAPI header/query/spec-fetch migration. -- OpenAPI OAuth/client credential migration. -- GraphQL header/query/auth migration. -- MCP HTTP header/query/auth migration. -- MCP stdio source survival. -- Collision detection where legacy slot canonicalization would collapse names. -- Existing source listing and tool invocation after migration. -- Secret/connection usage isolation after migration. - -## Implementation Order - -Even in one large PR, sequence the work internally: - -1. Document the architecture and update the existing notes. -2. Add/finish core source binding and configure registry. -3. Add generic plugin storage table/provider if not already present. -4. Create `@executor-js/plugin-http-source`. -5. Port OpenAPI to configure + HTTP helpers + JSON source config. -6. Port GraphQL to configure + HTTP helpers + JSON source config. -7. Port MCP with transport-discriminated configure. -8. Replace React credential flows with composed shared components. -9. Add one-shot migrations and migration tests. -10. Delete old plugin-specific binding APIs, stores, tables, atoms, and helpers. -11. Run full verification and fix fallout. - -## Success Criteria - -- Normal SDK users configure credentials through plugin-native configure APIs. -- Generic UI can call `executor.sources.configure(...)`. -- Core does not expose headers/query/OAuth as universal source concepts. -- OpenAPI/GraphQL/MCP do not each implement their own binding resolver/lister. -- HTTP credential UI changes apply to OpenAPI, GraphQL, and MCP HTTP. -- Plugin storage remains overridable and is backed by one shared plugin storage - table for plugin-private data. -- Source config remains first-class enough for source listing, tools, policies, - refresh, and scopes. -- Old plugin-specific credential child tables and endpoints are gone. diff --git a/notes/product-scope-language.md b/notes/product-scope-language.md deleted file mode 100644 index 50f2946dc..000000000 --- a/notes/product-scope-language.md +++ /dev/null @@ -1,47 +0,0 @@ -# Product Scope Language - -Executor has a real scope model, but the product should mostly avoid saying -"scope" to users. Use ownership and usage language instead. - -## Product Terms - -- **Personal**: only this user can use or update the credential/connection. -- **Organization**: everyone with access to the source can use the shared - credential/connection. -- **Source owner**: where the source definition and shared auth method live. - This is usually implicit from the current page/context and should not be - shown as debug information. -- **Used by**: who uses a specific credential value for a shared auth slot. -- **Saved to**: where a newly created secret or OAuth token/connection is - stored. - -## UI Rules - -- Communicate source auth as two separate choices: - - the shared authentication method for the source, such as bearer header, - query parameter, or OAuth; - - the credential/connection value used for that method. -- Do not imply users can change the auth method per person when the backend only - allows credential values to vary per scope. -- Put secret storage choices in the new-secret flow, because choosing Personal - or Organization there creates a reusable secret at that ownership level. -- Put credential usage choices next to the credential picker as **Used by**, - because attaching a secret to a source slot is separate from where the secret - itself is stored. -- Put OAuth token/connection storage next to **Connect via OAuth** as **Token - saved to**. This is independent from OAuth client ID/client secret storage. -- Secret lists should show secrets from all visible ownership levels with a - Personal/Organization badge. - -## Preferred Copy - -- Use "Personal" and "Organization" for selectors. -- Use "Used by" for source credential bindings. -- Use "Save secret to" in secret creation. -- Use "Token saved to" for OAuth sign-in results. -- Use "Add without credentials" whenever the source can be added with missing - initial credential values, not only for OAuth. - -Avoid copy like "scope", "target scope", "source scope", "binding scope", or -"credential target scope" in product UI unless it is explicitly a developer or -debug surface. diff --git a/notes/real-protocol-testing-plan.md b/notes/real-protocol-testing-plan.md deleted file mode 100644 index 867abae5f..000000000 --- a/notes/real-protocol-testing-plan.md +++ /dev/null @@ -1,448 +0,0 @@ -# Real Protocol Testing and Fetch Boundaries - -Executor's plugin tests should make realistic scenarios easy to write. For -OpenAPI, MCP, and GraphQL, that means tests should usually talk to real local -protocol servers instead of hand-written stubs, patched globals, or canned -responses. The boundary should still be deterministic and cheap: local port 0 -servers, in-memory stores, Effect layers, and explicit test services. - -This note covers the testing framework shape and the raw `fetch` lint boundary -needed to make protocol tests mockable through Effect. - -## Goals - -- Make realistic protocol scenarios easy to test for OpenAPI, MCP, and GraphQL. -- Reuse those same real protocol fixtures in CLI, local app, and cloud app e2e - tests so app-level suites prove the full invoke flow still works. -- Keep protocol-specific test services inside the plugin packages that own the - protocol. -- Keep shared SDK testing support limited to protocol-agnostic fixtures. -- Prefer Effect `Layer`/`TestLayer` composition over ad hoc setup functions. -- Remove test patterns that patch `globalThis.fetch`. -- Ban raw `fetch` in application and plugin code, except for narrow approved - boundary adapters and platform entrypoints. -- Keep parser/extractor unit tests cheap and pure where a real server adds no - value. - -## Non-goals - -- Do not build a large central test framework in core. -- Do not move plugin-specific protocol servers into `@executor-js/sdk`. -- Do not force every pure parsing test through HTTP. -- Do not expose test helpers through runtime `./api` exports. -- Do not add browser or worker harnesses for plugin SDK tests unless the - behavior actually depends on those runtimes. - -## Decisions - -Testing services belong behind explicit `./testing` subpath exports: - -```txt -@executor-js/sdk/testing -@executor-js/plugin-openapi/testing -@executor-js/plugin-mcp/testing -@executor-js/plugin-graphql/testing -``` - -The plugin packages own the protocol details. Core SDK testing owns only boring -Executor fixtures such as memory adapters, memory secrets, auto-accept -elicitation, and other Effect-native test primitives. - -Use `TestLayer` / `TestLayers` naming for realistic local test services. -Reserve `LiveLayer` / `LiveLayers` for production wiring. These servers are -real protocol servers, but they are still test services because their upstream -state and behavior are controlled by tests. - -Use `Layer.provideMerge` when a test needs access to both the live behavior and -the test service state, such as captured requests, session counts, issued -tokens, or mutable scenario refs. Use `Layer.fresh` where shared layer -memoization would leak state across tests. - -The same fixtures should be usable above the plugin SDK layer. A real OpenAPI, -MCP, or GraphQL fixture should be able to sit beside an `apps/cli`, -`apps/local`, or `apps/cloud` e2e harness and prove that the complete source -add, discovery, execute, approval, auth, and invoke path works through the -product entrypoint, not only through direct plugin calls. - -## Current State - -`packages/core/sdk/src/testing.ts` already provides the base Executor test -configuration through `makeTestConfig`. It creates the memory adapter, memory -blob store, test scopes, and auto-accept elicitation defaults. This is the -right home for cross-plugin Executor fixtures, but it is not enough for -protocol-specific scenarios. - -OpenAPI already has the strongest real-server pattern. In -`packages/plugins/openapi/src/sdk/plugin.test.ts`, tests define an Effect -`HttpApi`, serve it with `HttpRouter.serve`, provide -`NodeHttpServer.layerTest`, and pass the resulting `HttpClient` layer into the -plugin. This is the model to preserve and formalize. The weak spots are -duplicated helpers and OAuth tests such as -`packages/plugins/openapi/src/sdk/oauth-refresh.test.ts` that patch -`globalThis.fetch`. - -MCP already has a useful real HTTP helper in -`packages/plugins/mcp/src/sdk/test-utils.ts`. It starts a real node HTTP server, -creates `McpServer` instances, and routes `StreamableHTTPServerTransport` -sessions with `mcp-session-id`. That should become a plugin-owned testing -export rather than staying as private test utility code. The MCP shape probe in -`packages/plugins/mcp/src/sdk/probe-shape.ts` still accepts a fetch injection -and defaults to `globalThis.fetch`. - -GraphQL is the largest realism gap. `packages/plugins/graphql/src/sdk/plugin.test.ts` -mostly uses hand-written `introspectionJson`; one invocation path has a tiny -HTTP server but still uses canned introspection. The plugin should have a real -GraphQL test server that supports introspection and operation execution through -the same HTTP endpoint used by the plugin. - -Raw fetch usage is wider than these plugins. It appears in OAuth discovery, -OAuth helper tests, MCP probe tests, Google Discovery, cloud/local app tests, -release smoke tests, and some app runtime code. The lint rule should be added -with a clear boundary model and a temporary migration allowlist, not as a -blind repo-wide flip. - -## Target Package Shape - -Add source and publish exports for `./testing` in the SDK and each protocol -plugin package. - -```jsonc -{ - "exports": { - ".": "./src/sdk/index.ts", - "./testing": "./src/testing/index.ts", - }, -} -``` - -For `@executor-js/sdk`, either keep `src/testing.ts` as the subpath entrypoint -or move it to `src/testing/index.ts` with a compatibility export from the root -SDK. Avoid breaking existing imports of `makeTestConfig`. - -Suggested file layout: - -```txt -packages/core/sdk/src/testing.ts -packages/core/sdk/src/testing/ - memory-secrets.ts - -packages/plugins/openapi/src/testing/ - index.ts - server.ts - oauth-server.ts - -packages/plugins/mcp/src/testing/ - index.ts - server.ts - -packages/plugins/graphql/src/testing/ - index.ts - server.ts -``` - -The public testing surface should export services and layers, not large -scenario-specific suites. - -```ts -export class OpenApiTestServer extends Context.Service()("OpenApiTestServer", { - effect: Effect.gen(function* () { - return { - baseUrl, - specJson, - requests, - } as const; - }), -}) { - static readonly layer = (options: OpenApiTestServerOptions) => - Layer.effect(this, makeOpenApiTestServer(options)); -} - -export const TestLayers = { - openApiServer: OpenApiTestServer.layer, - oauthServer: OAuthTestServer.layer, -}; -``` - -The exact class/factory shape can follow local Effect style, but tests should -compose layers directly: - -```ts -layer(OpenApiTestLayers.itemsApi.pipe(Layer.provideMerge(SdkTestLayers.executor())))( - "OpenAPI plugin", - (it) => { - it.effect("invokes a real endpoint", () => - Effect.gen(function* () { - const server = yield* OpenApiTestServer; - // add source with server.specJson and invoke through the plugin - }), - ); - }, -); -``` - -## Shared SDK Testing - -Keep SDK testing small and protocol-neutral. - -Useful additions: - -- `memorySecretsPlugin()` or `MemorySecretsTestLayer`, replacing repeated - in-test secret provider definitions. -- `makeExecutorTestLayer(options)`, if repeated `createExecutor(makeTestConfig)` - setup becomes noisy. -- Small request/response capture primitives built on `Ref`, only if multiple - plugins need the same shape. - -Avoid generic "scripted server" abstractions unless at least two plugins need -the same non-trivial behavior. A protocol-specific server in the owning plugin -is easier to understand and less likely to leak protocol details into core. - -## OpenAPI Test Layers - -OpenAPI should first formalize the pattern that already works. - -Work: - -- Extract reusable server helpers from `plugin.test.ts` and related tests into - `packages/plugins/openapi/src/testing`. -- Keep Effect `HttpApi` as the main way to define test OpenAPI servers. -- Provide a helper that returns the bound base URL and spec JSON with - `servers: [{ url: baseUrl }]` already patched in. -- Add request capture for headers, query params, body bytes, and method/path. -- Add an OAuth authorization/token test server for client credentials, - authorization code, refresh success, refresh failure, and token rotation - scenarios. -- Migrate OAuth tests away from `globalThis.fetch` patching. - -Representative scenarios: - -- preview a generated spec from a real `HttpApi` -- add source from URL and from inline spec -- invoke GET and POST operations against the real server -- approval behavior for non-GET operations -- header/query secret resolution -- bearer token selection across scopes -- OAuth refresh and retry behavior -- non-JSON and validation-error response handling - -## MCP Test Layers - -MCP should promote the existing streamable HTTP helper into a public testing -surface and make the scenario state accessible through Effect. - -Work: - -- Move or wrap `packages/plugins/mcp/src/sdk/test-utils.ts` under - `packages/plugins/mcp/src/testing`. -- Export a `McpTestServer` service with `url`, `sessionCount`, captured - requests, and lifecycle handled by `Effect.acquireRelease`. -- Support a fresh `McpServer` factory per session, matching the current helper. -- Keep malformed/non-MCP HTTP endpoints as explicit test layers for probe - behavior. -- Replace fetch-injected probe tests with real local HTTP servers and - Effect-native HTTP boundaries. - -Representative scenarios: - -- discover tools from a real streamable HTTP MCP server -- invoke a real MCP tool -- multiple sessions remain isolated -- stale or unknown `mcp-session-id` returns protocol-accurate failure -- probe distinguishes real MCP from HTML, GraphQL errors, 400s, 404s, and - OAuth metadata redirects -- connection auth headers and secret-backed query params are sent correctly - -## GraphQL Test Layers - -GraphQL should gain a real executable schema server. This is the place where a -new dependency may be justified. - -Use `graphql-yoga` for the test server. The extra dependency is justified -because the fixture needs to exercise a real JSON-over-HTTP GraphQL endpoint, -not only direct `graphql(...)` execution. Keep Vitest configured to inline the -Yoga/GraphQL dependency chain so executable schemas and server execution share -one GraphQL module realm. - -Work: - -- Add `packages/plugins/graphql/src/testing` with a `GraphqlTestServer` layer. -- Support executable schemas with query and mutation resolvers. -- Return real introspection results from the server instead of canned JSON. -- Capture requests and headers so auth behavior is assertable. -- Provide helpers for common schema fixtures, but keep custom schema creation - easy. -- Migrate plugin behavior tests from `introspectionJson` to a live endpoint. -- Keep pure extraction tests on static introspection JSON where that is the - unit under test. - -Representative scenarios: - -- add source by introspecting a real endpoint -- register query and mutation tools from the real schema -- invoke query with variables -- invoke mutation and require approval -- propagate GraphQL `errors` envelopes -- attach static headers and secret-backed bearer tokens -- update source endpoint/headers without re-registering tools -- handle schema changes on refresh or re-add - -## Fetch Boundary - -The target rule is: product and plugin code should not call raw `fetch` -directly. HTTP should go through Effect HTTP. If a third-party library truly -forces a fetch-shaped callback, the adapter should live at that owning package's -boundary rather than in shared SDK testing. - -Boundary files may exist, but they should be explicit and scarce. Examples: - -- a core OAuth adapter for `oauth4webapi` custom fetch, if that library forces it -- a MCP transport adapter if the upstream MCP SDK requires fetch-shaped input -- platform entrypoints that must implement a `fetch(request, env, ctx)` method -- test harnesses that intentionally call a Worker binding's `fetch` method - -Everything else should use Effect HTTP, Executor client APIs, or an explicit -package-local boundary. - -Lint plan: - -- Add `executor/no-raw-fetch` under `scripts/oxlint-plugin-executor/rules`. -- Register it in `scripts/oxlint-plugin-executor.js` and `.oxlintrc.jsonc`. -- Detect direct calls to global `fetch(...)` and `globalThis.fetch(...)`. -- Detect assignments or defaulting to `globalThis.fetch`, because that usually - preserves the same ambient dependency under another name. -- Do not flag object methods named `fetch` by default, so Worker handlers and - bindings can be managed through explicit allowlist rules rather than noisy - false positives. -- Start with a narrow allowlist for known boundary files and remove entries as - migrations land. -- Include error messages that tell the author which Effect HTTP or approved - package-local boundary to use. - -The lint rule can land once the core test primitives and at least one real -protocol fixture exist. Fetch-shaped adapters should be added only at forced -library boundaries during migration. - -## Migration Order - -1. Add `./testing` exports and SDK test primitives. - - Expose `@executor-js/sdk/testing`. - - Add memory secrets test support. - - Keep `makeTestConfig` available from the root SDK. - -2. Build the GraphQL vertical slice. - - Add the real GraphQL test server layer. - - Migrate a representative set of GraphQL plugin tests off canned - `introspectionJson`. - - Keep pure extractor tests separate. - -3. Promote OpenAPI and MCP existing helpers. - - Move OpenAPI real `HttpApi` helpers into `./testing`. - - Move MCP streamable HTTP helper into `./testing`. - - Migrate current tests to import from the new public testing subpaths. - -4. Replace global fetch patching. - - Convert core OAuth discovery/helper tests to real local servers or - package-local Effect HTTP boundaries. - - Convert OpenAPI OAuth tests to the OAuth test server. - - Convert MCP probe tests to real local HTTP server layers. - - Convert Google Discovery tests using the same boundary pattern. - -5. Add and enforce the raw fetch lint rule. - - First enforce it for plugin SDK packages and core SDK. - - Then tighten app code once cloud/local worker-specific boundaries are - classified. - - Remove temporary allowlist entries as each package is migrated. - -6. Broaden scenario coverage. - - Add failure-mode suites for auth, malformed protocol responses, refresh - flows, schema changes, and multi-scope source shadowing. - - Prefer one real server layer plus scenario state over many one-off stubs. - -7. Reuse fixtures in app-level e2e tests. - - Add CLI e2e coverage that starts a real protocol fixture, adds the source - through the CLI-supported path, invokes a tool, and asserts the fixture saw - the expected request. - - Add local app coverage that drives the local API/server path through the - same source add and invoke flow. - - Add cloud app coverage that runs the worker/miniflare harness against real - protocol fixtures where the runtime supports it. - - Keep app e2e assertions focused on integration boundaries: transport, - source persistence, auth/secret resolution, tool discovery, elicitation, - and invocation. - -## Verification - -Use Vitest for test execution, not `bun test`. - -Targeted commands during migration: - -```sh -vitest run packages/plugins/graphql/src/**/*.test.ts -vitest run packages/plugins/openapi/src/**/*.test.ts -vitest run packages/plugins/mcp/src/**/*.test.ts -vitest run packages/core/sdk/src/**/*oauth*.test.ts -vitest run apps/local/src/**/*.test.ts -vitest run apps/cloud/src/**/*.test.ts -``` - -Repo-level checks before merging: - -```sh -bun run lint -bun run typecheck -bun run test -``` - -The root `bun run test` delegates to package `vitest run` scripts through -Turbo. Do not use `bun test`. - -## Open Questions - -- Should `@executor-js/sdk/testing` be a new subpath around the existing - `src/testing.ts`, or should `src/testing.ts` become `src/testing/index.ts`? -- How much GraphQL HTTP behavior should live in the shared fixture versus - scenario-specific schemas in individual tests? -- Which app-level raw fetch calls should remain approved platform boundaries - versus being migrated to Effect HTTP? -- Should the raw fetch lint rule become repo-wide immediately with allowlists, - or package-scoped first for core SDK and protocol plugins? -- How much of the existing cloud MCP real-port harness should be promoted - versus left as an app-specific integration harness? - -## References - -Current Executor code: - -- `packages/core/sdk/src/testing.ts` -- `packages/plugins/openapi/src/sdk/plugin.test.ts` -- `packages/plugins/openapi/src/sdk/oauth-refresh.test.ts` -- `packages/plugins/mcp/src/sdk/test-utils.ts` -- `packages/plugins/mcp/src/sdk/probe-shape.ts` -- `packages/plugins/graphql/src/sdk/plugin.test.ts` -- `scripts/oxlint-plugin-executor.js` -- `.oxlintrc.jsonc` -- `notes/old/mcp-testing.md` - -Effect reference patterns: - -- `https://github.com/Effect-TS/effect-smol` -- local reference commit: - `.reference/effect-smol` at `f862e40573b6d1c04942799be5ff6f7dbea22ae9` -- `ai-docs/src/09_testing/20_layer-tests.ts` for `layerTest` and - `Layer.provideMerge` test state exposure -- `packages/platform-node/src/NodeHttpServer.ts` for `NodeHttpServer.layerTest` - and test client wiring - -t3code reference patterns: - -- `https://github.com/pingdotgg/t3code` -- local reference commit: - `.reference/t3code` at `22384ae977a362c547d6f57d4e3d92bbe55ee5db` -- `apps/server/src/config.ts` for service-local `layerTest` -- `apps/server/src/serverSettings.ts` for in-memory test service layers -- `apps/server/integration/OrchestrationEngineHarness.integration.ts` for - realistic integration layer composition with explicit fakes at the edges -- `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts` for real - filesystem/git style tests composed through layers -- `apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts` for - scenario-specific harnesses that stay near the owning domain diff --git a/notes/research/plugins/architecture.md b/notes/research/plugins/architecture.md deleted file mode 100644 index 745a8e9b2..000000000 --- a/notes/research/plugins/architecture.md +++ /dev/null @@ -1,259 +0,0 @@ -# Executor Plugin Architecture - -Executor's plugin system is the main extension mechanism for the product. It should let Executor add new capabilities without putting every capability into the core SDK, and it should let users, teams, vendors, and the community extend Executor without waiting on first-party product work. - -This document is an architecture planning document. It describes the intended shape of the plugin system and the boundaries it needs to preserve. It does not finalize exact APIs, manifest schemas, packaging formats, storage schemas, or sandbox internals. - -## Goals - -Plugins should be a core part of Executor, not an optional add-on. - -The plugin system should support first-party plugins and third-party plugins with the same conceptual model. First-party plugins may be operationally more trusted, but they should not rely on privileged extension paths that third-party plugins can never use. - -Plugins should work locally and in the cloud through the same product architecture. The runtime implementation can differ, but plugin authors and Executor product surfaces should not have to reason about separate local-only and cloud-only plugin systems. - -Plugins should be capability-based. Loading a plugin should not imply access to all tools, all secrets, all scopes, the filesystem, the network, or host runtime APIs. - -Plugins should compose with Executor's existing primitives: tools, sources, scopes, secrets, definitions, connections, and execution. - -## Non-Goals - -This document does not define the exact plugin manifest schema. - -This document does not define the exact plugin authoring API. - -This document does not choose final local sandbox internals. - -This document does not choose final cloud worker packaging internals. - -This document does not define every extension point Executor will ever support. - -This document does not make workflow, generated UI, or custom tool product decisions beyond describing how plugins should be able to provide those capabilities. - -## Core Model - -Executor should separate plugin authoring from plugin execution. - -Plugin authoring is the developer-facing model: a plugin package, manifest, registration API, lifecycle hooks, and declared capabilities. - -Plugin execution is the runtime model: how plugin code is loaded, isolated, granted capabilities, called, limited, persisted, and cleaned up. - -This separation matters because the ergonomic plugin API and the safe execution boundary are different problems. References like Pi and opencode are useful for plugin authoring and lifecycle. References like emdash, kody, and secure-exec are useful for untrusted execution. - -## Plugin Types - -Executor should support multiple plugin categories through one conceptual plugin system. - -### Source Plugins - -Source plugins add new ways to produce sources and tools. OpenAPI, GraphQL, and MCP should likely be first-party source plugins. - -A source plugin may define how a source is configured, how tools are discovered, how tools are invoked, and how source-specific auth works. - -### Secret Store Plugins - -Secret store plugins add new ways to store, retrieve, or route secrets. - -These plugins are sensitive because they sit near credential material. They need stronger capability and trust review than plugins that only contribute static metadata. - -### Execution-Adjacent Plugins - -Execution-adjacent plugins add capabilities built on top of code execution. A custom-tools plugin is the clearest example. It would let users or agents define code-backed tools that become part of the same source/tool model. - -The important distinction is that custom tools are not a special core primitive. They are likely a first-party plugin built using core primitives. - -### Product Capability Plugins - -Product capability plugins add features that are useful across product surfaces but do not belong directly in the core SDK. Examples include execution logs, audit views, source discovery helpers, importers, admin UI, or workflow-related features. - -## First-Party And Third-Party Plugins - -First-party plugins are maintained by Executor. They should provide common capabilities without expanding the core SDK unnecessarily. - -Likely first-party plugins include OpenAPI, GraphQL, MCP, custom tools, common secret storage adapters, execution logs, and source discovery helpers. - -Third-party plugins are maintained by users, vendors, teams, or the community. They should be treated as untrusted by default. - -The plugin system should avoid creating a privileged first-party-only path unless a feature genuinely requires one. If a first-party plugin needs capabilities that third-party plugins cannot safely receive, that difference should be explicit. - -## Plugin Manifest - -Plugins should have a manifest that is validated before execution. - -At a high level, the manifest should describe identity, version, compatibility, entrypoints, plugin category, declared capabilities, extension points, and optional UI or asset bundles. - -The manifest should support immutable plugin versions. Runtime caching, artifact storage, auditability, and rollback are all easier if a plugin version maps to stable code and stable declared metadata. - -The manifest should also include compatibility metadata, such as supported Executor versions. This avoids loading plugins against incompatible host APIs. - -The exact schema is a follow-up design topic. - -## Registration And Lifecycle - -Plugins should register extension points through a small typed API. - -The authoring model should prefer explicit registration over ambient mutation. A plugin should declare what it contributes: source handlers, tool providers, secret store adapters, routes, UI surfaces, lifecycle hooks, or execution capabilities. - -Plugin loading should be deterministic. Executor can resolve and prepare plugins in parallel, but activation should happen in a stable order so side effects, conflicts, and diagnostics are predictable. - -Plugin lifecycle should include initialization and disposal. Runtime contexts should not be reusable after plugin reload, process restart, scope changes, or product session changes. - -Executor should track plugin provenance for registered capabilities. When a tool, source, secret adapter, route, or UI surface exists because of a plugin, the product should know which plugin and version provided it. - -## Conflict Policy - -Executor should define conflict behavior early. - -Source IDs, tool IDs, routes, UI surfaces, and secret store adapters can collide. The system should avoid silent ambiguous behavior. - -Possible policies include first-wins, inner-scope-wins, explicit override, namespaced IDs, or install-time rejection. Different extension points may need different policies. - -For source and tool visibility, Executor should preserve the existing scope model: reads resolve through an ordered scope stack, and writes target an explicit scope. Plugin-provided tools and sources should participate in that model rather than bypassing it. - -## Capability Model - -Plugins should not receive ambient authority. - -A plugin should only be able to access the tools, sources, scopes, secrets, storage, network destinations, host APIs, and execution features it has been granted. - -Capabilities should be declared by the plugin, reviewed or approved by the user or organization, and enforced by the runtime. - -The capability model needs to distinguish between declaration and grant. A plugin can declare that it needs access to a secret, network host, storage namespace, or tool invocation API. The host decides whether to grant that access for a specific scope, user, organization, or installation. - -Missing capability should mean hard denial. It should not fall back to best-effort behavior. - -## Scopes And Plugins - -Scopes define ownership and visibility for Executor resources. Plugins need to integrate with scopes rather than inventing a separate tenancy model. - -Plugin installation should likely be scoped. A plugin may be installed for a user, workspace, organization, or another host-defined scope. - -Plugin-created resources should write to explicit scopes. Plugin reads should resolve through the Executor scope stack only when the plugin has permission to read those scopes. - -Plugin storage should be namespaced by plugin identity and scope. This prevents collisions between plugins and prevents a plugin from using generic storage as a side channel into another plugin's state. - -## Runtime Boundary - -Executor should assume third-party plugin code is untrusted. - -The runtime boundary should be built around a narrow host-controlled bridge. Plugin code should not receive direct access to host process APIs, database clients, secret values, local filesystem access, platform bindings, Durable Objects, or unrestricted network fetch. - -Instead, plugin code should call host-provided APIs. Those APIs enforce capabilities, validate payloads, cap sizes, redact sensitive data, and return structured results. - -This bridge is part of the attack surface. It needs explicit method definitions, typed arguments, size limits, structured errors, request IDs, timeouts, and audit hooks. - -## Cloud Runtime - -Cloud plugin execution should use isolated worker execution. - -Cloudflare Dynamic Worker Loaders are the expected primitive for running untrusted plugin code in the cloud. A plugin version should be loaded as an immutable worker bundle, and the host should call it through a narrow bridge. - -Remote plugins should not receive ambient network access. Direct outbound network should be disabled where possible, and network access should route through a host-controlled fetch API that can enforce allowlists, SSRF protections, redirect validation, and credential stripping. - -Cloud runtime state should not live inside the isolate. Isolates should be treated as cacheable but disposable. Durable state should live in host-managed storage such as D1, KV, R2, Durable Objects, or Executor's own storage adapters, always namespaced by plugin, version, tenant, and scope as appropriate. - -Worker-loader stubs may need to be request-scoped when runtime bindings include request-specific, tenant-specific, or grant-specific data. The loaded code can be cached by immutable plugin version, but per-request authority should not be cached globally. - -## Local Runtime - -Local plugin execution should use an isolated JavaScript runtime rather than importing untrusted plugin code into the host process. - -V8 isolates are the expected local primitive. Local plugins should run with no direct Node globals for filesystem, process, environment, network, child processes, or host module resolution. - -Local filesystem access should be denied by default. If a plugin needs files, it should receive explicit mounted paths or virtual filesystem access. Host paths should be canonicalized, symlinks handled carefully, and native addons blocked unless there is a deliberate trusted path. - -Local network access should be denied by default. If granted, it should be host, port, and protocol constrained, with private network, link-local, metadata, and loopback behavior handled deliberately. - -Local execution needs budgets beyond simple timeout: heap, wall time, CPU time where possible, output size, bridge call count, bridge payload size, active handles, timers, and child process count if subprocesses are ever supported. - -## Plugin Storage And Artifacts - -Plugins need durable storage, but storage should be host-mediated. - -Plugin storage should be namespaced by plugin ID, plugin version where relevant, installation scope, and tenant. The storage API should prevent plugins from reading or writing another plugin's data. - -Plugin code and assets should be treated as artifacts. Published plugin versions should map to immutable artifacts with validated manifests, bundle metadata, checksums, and compatibility information. - -Cloud storage can use a split model: object storage for bundle artifacts and database rows for searchable metadata, installation state, and indexes. - -Local storage can use the same conceptual artifact model with local files or database rows. The important product behavior is that local and cloud agree on plugin identity, versioning, grants, and installed state. - -## Distribution And Installation - -Executor should support local development and package-based distribution. - -Local development should support loading a plugin from a local path for iteration. Path-based plugins should require explicit IDs because there may be no package name to fall back to. - -Package-based plugins should support stable IDs, versions, compatibility metadata, and validated entrypoints. Install-time package scripts should be disabled when fetching third-party packages. - -The install flow should record provenance: where the plugin came from, which scope installed it, which version is installed, which capabilities were declared, and which capabilities were granted. - -Executor should support a safe mode or pure mode that disables external plugins while still allowing core and internal functionality to start. - -## Extension Points - -Initial extension points should be conservative and tied to known product needs. - -Likely extension points include source providers, tool providers, tool invocation handlers, secret stores, source importers, plugin routes, execution hooks, logs, UI surfaces, and background services. - -Each extension point should define its trust boundary. Some extension points only contribute metadata. Others invoke code, access secrets, perform network calls, or affect authorization. They should not all share the same default capabilities. - -Extension points should prefer explicit inputs and outputs over mutation of host objects. Where ordered mutation is needed, Executor should make ordering deterministic and visible. - -## Background Services And Durable Work - -Some plugins may need more than request/response execution. - -Cloud plugins may need background services, scheduled jobs, retries, or realtime sessions. Durable Objects are a likely fit for long-lived coordination, per-plugin service state, websocket sessions, and serialized per-tenant execution. - -This should be modeled explicitly rather than hidden inside plugin request handlers. A plugin manifest should eventually be able to distinguish between request handlers, background services, jobs, and UI assets. - -Local equivalents may not use the same primitives, but should preserve the same conceptual lifecycle where possible. - -## Security Principles - -Plugin code should be untrusted by default. - -All sensitive host access should go through capability-checked APIs. - -Secrets should not be passed as ambient environment variables. Plugins should request secret-backed operations through host APIs, or receive scoped secret material only when explicitly granted. - -Network access should be denied by default and mediated when granted. - -Filesystem access should be denied by default and mediated when granted. - -Plugin storage should be namespaced and scoped. - -Payload sizes, output sizes, runtime duration, and bridge calls should be bounded. - -Plugin errors should be isolated. A broken plugin should fail to load, fail its own operation, or be disabled without preventing Executor from starting when possible. - -## Architecture Implications - -Executor core should own the primitive contracts: tools, sources, scopes, secrets, plugin registration, capability grants, and execution bridge interfaces. - -Executor core should not own every product capability. OpenAPI, GraphQL, MCP, custom tools, execution logs, workflow-related features, and generated UI support can be provided by plugins where practical. - -The plugin system needs to be designed before many product features are finalized because it determines whether those features become core, first-party plugins, or third-party extension points. - -Local and cloud implementations should share tests or conformance fixtures where possible. The exact runtime differs, but a plugin capability should mean the same thing in both environments. - -## Reference Lessons - -emdash demonstrates the cloud-side pattern Executor likely wants: Dynamic Worker Loader, no ambient network, bridge-mediated access, serialized request boundaries, and durable state outside the isolate. - -kody demonstrates a broader Cloudflare operational model: separate ephemeral execution and app/service workers, Durable Objects for stateful coordination, deterministic bundle artifacts, and tenant-namespaced runtime state. - -secure-exec demonstrates the local runtime shape: V8 isolates, deny-by-default permissions, virtual filesystem, mediated network, explicit module policy, and resource budgets beyond timeout. - -Pi demonstrates an ergonomic plugin authoring model: small typed API, registration during load, lifecycle events, package discovery, and composable extension points. It does not provide the security model Executor needs. - -opencode demonstrates practical plugin loading and distribution: simple config shape, provenance tracking, compatibility checks, deterministic activation, install-time script suppression, safe mode, and lifecycle cleanup. Its plugins are trusted, so Executor should borrow ergonomics but not the trust assumption. - -## Follow-Up Architecture Work - -The next research steps should turn this plan into narrower architecture documents. - -Recommended follow-ups include plugin manifest and package format, plugin authoring API, capability declaration and grant model, cloud Dynamic Worker runtime, local V8 isolate runtime, plugin storage and artifact model, source plugin contract, secret store plugin contract, custom-tools plugin design, background service model, plugin UI model, and conformance testing across local and cloud. - -Each follow-up should make concrete decisions in its own area while preserving the product model from `notes/research/product-model.md` and the plugin boundaries described here. diff --git a/notes/research/plugins/manifest-schema.md b/notes/research/plugins/manifest-schema.md deleted file mode 100644 index 894a92fca..000000000 --- a/notes/research/plugins/manifest-schema.md +++ /dev/null @@ -1,471 +0,0 @@ -# Plugin Manifest Schema - -Executor plugins need a manifest that can be validated before plugin code runs. The manifest is the stable contract between a plugin package, the Executor host, the installer, the capability grant system, and the local/cloud runtimes. - -This document starts the manifest schema planning work. It proposes the responsibilities and rough shape of the manifest, but does not finalize the exact JSON schema, TypeScript types, package source format, or registry format. - -## Goals - -The manifest should make plugin identity explicit. - -The manifest should describe immutable plugin versions and compatibility with Executor. - -The manifest should declare the plugin's entrypoints and extension points without requiring the host to run plugin code first. - -The manifest should declare requested capabilities so users, organizations, and hosts can review and grant them before execution. - -The manifest should support both local and cloud execution using the same conceptual schema. - -The manifest should support first-party and third-party plugins without creating a separate schema for each. - -The manifest should give installers enough information to validate, store, audit, cache, and load plugin packages from npm, GitHub/git, local paths, or a future registry. - -## Non-Goals - -The manifest should not encode every runtime detail. - -The manifest should not contain granted capabilities. It should declare requested capabilities. Grants are installation-specific state owned by the host. - -The manifest should not contain secret values. - -The manifest should not require one plugin type per package if multiple entrypoints are useful, but it should keep each entrypoint explicit. - -The manifest should not replace plugin registration. Some details can still be registered by code at load time, but safety-relevant and install-relevant details should be visible before code execution. - -## Responsibilities - -The manifest has five main jobs. - -### Identity - -The manifest identifies the plugin and version. - -Identity should be stable across local and cloud. It should be possible to answer which plugin version registered a source, tool, route, background service, UI surface, or storage namespace. - -### Compatibility - -The manifest declares which Executor versions and runtime APIs the plugin expects. - -Compatibility should be checked before loading the plugin. - -### Entrypoints - -The manifest declares the code entrypoints the host may load. - -Entrypoints should be explicit because different entrypoints may run in different contexts: source provider, secret store, route handler, background service, UI asset, or admin UI. - -### Capabilities - -The manifest declares requested capabilities. - -Capability declarations are not grants. They are the plugin's requested access. The host decides which requested capabilities are granted for a specific installation, scope, user, organization, or environment. - -### Package Sources - -The manifest describes the package enough for validation and loading, but Executor should not require a custom distribution system at the start. - -The default distribution path should be existing package distribution: npm packages, GitHub/git sources, and local paths for development. A future Executor registry can layer on top of the same manifest rather than replacing it. - -## Proposed Top-Level Shape - -The manifest should likely be a JSON-serializable object with these top-level sections: - -```ts -type PluginManifest = { - schemaVersion: string; - id: string; - name: string; - version: string; - description?: string; - publisher?: PluginPublisher; - license?: string; - homepage?: string; - repository?: string; - categories?: PluginCategory[]; - compatibility: PluginCompatibility; - entrypoints: PluginEntrypoints; - capabilities?: PluginCapabilities; - extensions?: PluginExtensions; - storage?: PluginStorageDeclaration; - package?: PluginPackageDeclaration; - metadata?: Record; -}; -``` - -This is a planning type, not a final API. - -## Identity Fields - -### `schemaVersion` - -The manifest schema version. - -This lets Executor evolve the manifest format without guessing based on plugin version. - -### `id` - -The stable plugin ID. - -The ID should be globally meaningful for published plugins and locally meaningful for path-based development plugins. It should not depend on install scope. - -Plugin IDs should likely support npm-style package names because npm distribution is the default path. GitHub/git and future registry distribution should still resolve to the same stable plugin ID from the manifest. - -### `name` - -Human-readable plugin name. - -### `version` - -Immutable plugin version. - -Published plugin artifacts should be immutable for a given `id` and `version`. If code changes, the version should change. - -### `publisher` - -Optional publisher identity. - -This can support trust decisions, provenance, future signing, and future registry display. - -## Package Sources - -Executor should not reinvent plugin distribution before it needs to. - -The install surface should support package sources rather than assuming one custom marketplace or registry. Initial sources should include npm packages, GitHub/git repositories, and local paths. A future Executor registry should be an additional package source, not a replacement for those. - -User-facing install config can stay simple: - -```ts -type PluginInstallSpec = string | [string, Record]; -``` - -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. - -Resolved install metadata should be stored separately from the manifest. It should track the original spec, resolved source type, resolved version or commit, package root, manifest path, installed scope, timestamps, and load status. - -```ts -type PluginPackageDeclaration = { - source?: "npm" | "git" | "github" | "local" | "registry"; - packageName?: string; - repository?: string; -}; -``` - -This `package` block is optional planning shape, not final schema. For npm packages, standard `package.json` fields may already provide most of this information. - -## Compatibility - -Compatibility should be explicit and checked before plugin code is loaded. - -```ts -type PluginCompatibility = { - executor: string; - runtimeApi?: string; - platforms?: PluginPlatform[]; -}; - -type PluginPlatform = "local" | "cloud"; -``` - -`executor` should probably be a semver range for supported Executor versions. - -`runtimeApi` can version the plugin bridge API separately from the product version if needed. - -`platforms` lets a plugin declare whether it supports local, cloud, or both. The default should likely be both only if all declared entrypoints can run in both environments. - -Open question: whether platform support belongs at the top level, per entrypoint, or both. - -## Entrypoints - -Entrypoints describe executable code or assets the host may load. - -```ts -type PluginEntrypoints = { - main?: PluginCodeEntrypoint; - sdk?: PluginCodeEntrypoint; - source?: PluginCodeEntrypoint; - secretStore?: PluginCodeEntrypoint; - apiGroup?: PluginCodeEntrypoint; - apiHandlers?: PluginCodeEntrypoint; - routeHandlers?: Record; - backgroundServices?: Record; - reactSource?: PluginUiEntrypoint; - reactSecretProvider?: PluginUiEntrypoint; - ui?: PluginUiEntrypoint; - adminUi?: PluginUiEntrypoint; -}; - -type PluginCodeEntrypoint = { - module: string; - export?: string; - runtime?: PluginRuntimeTarget; -}; - -type PluginRuntimeTarget = "worker" | "isolate" | "host-trusted"; -``` - -The exact shape may change, but the manifest should avoid requiring the host to import a plugin just to discover what code exists. - -Most third-party plugin code should target `worker` and `isolate` style execution, not `host-trusted` execution. - -`host-trusted` may still be useful for internal plugins or development, but it should be explicit and not the default for third-party plugins. - -Open question: whether source plugins should have a dedicated `source` entrypoint or register source behavior through `main`. - -## SDK-Only Versus API And React Usage - -Executor has two different consumption modes that the manifest needs to support. - -An SDK-only consumer embeds Executor directly in an application. They need the SDK plugin factory and any required storage schemas, but they do not necessarily need HTTP routes, API handlers, React pages, or UI descriptors. - -An API and React host needs more than the SDK plugin. It may need SDK registration, API route groups, API handlers, service bindings from the SDK executor extension, and React UI descriptors. - -The manifest should make those layers explicit. Installing a plugin into the SDK should not automatically expose HTTP routes. Adding React UI should not imply the backend plugin exists. Exposing API routes should not imply a UI is available. - -Current first-party plugins follow this layered shape: - -- OpenAPI: SDK plugin, API group/handlers, React source UI. -- MCP: SDK plugin, API group/handlers, React source UI, with stdio support gated by host configuration. -- GraphQL: SDK plugin, API group/handlers, React source UI. -- WorkOS Vault: SDK secret provider plugin and optional React secret provider UI, with no required source UI. - -The manifest should declare which SDK plugin ID each API or React entrypoint expects. That makes it possible for hosts to validate that a UI descriptor is not enabled without the matching SDK/API capability. - -Example planning shape: - -```ts -type PluginLayerDeclaration = { - sdk?: { - pluginId: string; - entrypoint: keyof PluginEntrypoints; - }; - api?: { - requiresSdkPlugin: string; - groupEntrypoint?: keyof PluginEntrypoints; - handlersEntrypoint?: keyof PluginEntrypoints; - }; - react?: Array<{ - kind: "source" | "secret-provider" | "settings" | "admin"; - requiresSdkPlugin?: string; - requiresApi?: boolean; - entrypoint: keyof PluginEntrypoints; - }>; -}; -``` - -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. - -Plugin options may also need to be layered. MCP is the current example: the SDK/API host can disable stdio MCP, while the React source UI must not expose stdio flows unless the server-side capability is enabled. The manifest and grant model should provide a way to represent these coupled options so UI does not promise behavior the SDK/API layer denies. - -## Extensions - -Extensions declare what the plugin contributes at a product level. - -```ts -type PluginExtensions = { - sources?: SourceExtensionDeclaration[]; - secretStores?: SecretStoreExtensionDeclaration[]; - tools?: ToolExtensionDeclaration[]; - routes?: RouteExtensionDeclaration[]; - uiSurfaces?: UiSurfaceDeclaration[]; - backgroundServices?: BackgroundServiceDeclaration[]; -}; -``` - -Some extension declarations may be fully static. Others may be partial declarations that are completed by plugin registration at load time. - -The dividing line should be based on safety and install UX. Anything needed for capability review, routing, artifact loading, or policy decisions should be present in the manifest. - -## Capabilities - -Capabilities declare requested access. Grants are separate host state. - -```ts -type PluginCapabilities = { - tools?: ToolCapabilityDeclaration[]; - sources?: SourceCapabilityDeclaration[]; - secrets?: SecretCapabilityDeclaration[]; - network?: NetworkCapabilityDeclaration[]; - storage?: StorageCapabilityDeclaration[]; - scopes?: ScopeCapabilityDeclaration[]; - filesystem?: FilesystemCapabilityDeclaration[]; - execution?: ExecutionCapabilityDeclaration[]; - host?: HostCapabilityDeclaration[]; -}; -``` - -Capabilities should be deny-by-default. If a capability is missing, the runtime should deny that access. - -### Tool And Source Capabilities - -Tool and source capabilities should describe whether the plugin wants to discover tools, invoke tools, create tools, update tools, delete tools, or manage sources. - -These capabilities need to compose with scopes. A plugin may request a kind of access, but the grant decides which scopes, sources, or tools it applies to. - -### Secret Capabilities - -Secret capabilities should distinguish between reading secret material and using a secret indirectly. - -Most plugins should not need raw secret values. They should prefer host-mediated operations where possible. - -### Network Capabilities - -Network capabilities should declare allowed outbound hosts, protocols, and possibly ports. - -The runtime should still enforce SSRF protections and redirect validation even when a host is allowed. - -Open question: whether the manifest supports broad network capabilities like `network:any`, and whether those are first-party only, admin-only, or disallowed. - -### Storage Capabilities - -Storage capabilities should declare plugin storage namespaces or collections. - -The runtime should namespace actual storage by plugin ID and installation scope regardless of what the plugin declares. - -### Filesystem Capabilities - -Filesystem capabilities are mainly relevant locally. - -They should be denied by default and should describe requested mount points or abstract mounts rather than arbitrary host paths where possible. - -Open question: whether third-party plugins should be allowed direct filesystem mounts at all, or whether filesystem access should be reserved for explicitly trusted/local plugins. - -### Execution Capabilities - -Execution capabilities describe whether the plugin can run code, define code-backed tools, spawn background work, or call execution APIs. - -This is especially important for a custom-tools plugin because it is likely a first-party plugin that exposes controlled code execution to users or agents. - -## Storage Declaration - -The manifest should let plugins declare storage needs separately from storage grants. - -```ts -type PluginStorageDeclaration = { - collections?: Array<{ - name: string; - kind: "kv" | "document" | "sql"; - description?: string; - }>; -}; -``` - -Storage declarations help the host pre-create, migrate, display, or approve plugin storage. - -Open question: whether plugin storage migrations belong in the manifest, in code, or in a separate artifact. - -## Artifacts And Caching - -Plugins should be cacheable after they are resolved from their package source. - -Executor does not need a custom publishing flow immediately. npm, GitHub/git, and local paths can be the initial distribution mechanisms. After a package is resolved, Executor can still cache the resolved package or build output as an internal artifact for repeatable local/cloud loading. - -```ts -type PluginArtifacts = { - files?: Array<{ - path: string; - size?: number; - sha256?: string; - kind?: "code" | "asset" | "manifest" | "source-map"; - }>; - bundle?: { - format: "esm"; - main: string; - size?: number; - sha256?: string; - }; -}; -``` - -Cloud and local do not need to store cached artifacts the same way, but they should agree on immutable identity and verification where possible. - -For npm packages, immutable identity can come from package name and version plus package integrity metadata when available. - -For GitHub/git sources, immutable identity should come from a resolved commit SHA rather than a branch name. - -For local paths, immutable identity may be best-effort during development and should be treated differently from published packages. - -Open question: whether cached artifact metadata lives inside the manifest, beside the manifest, or only in install metadata produced after resolution. - -## Example Sketch - -This example is intentionally incomplete and not final syntax. - -```json -{ - "schemaVersion": "0.1", - "id": "@executor/openapi", - "name": "OpenAPI", - "version": "0.1.0", - "description": "Create Executor sources and tools from OpenAPI specs.", - "compatibility": { - "executor": ">=0.1.0", - "runtimeApi": "0.1", - "platforms": ["local", "cloud"] - }, - "entrypoints": { - "source": { - "module": "./dist/source.js", - "export": "default", - "runtime": "worker" - } - }, - "extensions": { - "sources": [ - { - "kind": "openapi", - "displayName": "OpenAPI" - } - ] - }, - "capabilities": { - "network": [ - { - "hosts": ["*"] - } - ], - "storage": [ - { - "collection": "sources" - } - ] - } -} -``` - -This example raises an immediate design issue: OpenAPI may need broad network access to call arbitrary APIs from imported specs. That capability is powerful and may need special grant UX, source-level grants, or host-mediated request policies rather than a simple wildcard. - -## Open Questions - -Should the manifest be embedded in `package.json`, stored as `executor.plugin.json`, or both? - -Should plugin IDs be required to match npm package names when distributed through npm, or can one package expose a different manifest ID? - -What exact source spec grammar should Executor support for npm, GitHub/git, registry, and local path plugins? - -Should GitHub support mean git clone, GitHub tarball download, npm-style `github:owner/repo`, or all of them? - -How should a future Executor registry compose with npm/GitHub distribution instead of replacing it? - -Should a plugin package support multiple plugins, or should there be exactly one plugin per package? - -Should extension declarations be fully static, or should code registration define most contributions after the manifest passes validation? - -Should platform support be declared globally, per entrypoint, or per extension? - -How should first-party-only or trusted-only capabilities be represented? - -How should package integrity, checksums, provenance, and publisher verification work across npm, GitHub/git, local paths, and future registry packages? - -How much artifact metadata belongs in the manifest versus registry metadata? - -How should manifest-declared capabilities map to installation-specific grants and scope-specific policies? - -How should manifest validation work for local development plugins that may not be fully bundled yet? - -## Next Steps - -The next planning pass should choose the manifest file location and exact top-level shape. - -After that, the capability declaration and grant model should be designed because it will affect the manifest schema more than any other subsystem. - -The source plugin contract should also be designed early because OpenAPI, GraphQL, and MCP are likely first-party source plugins and will validate whether the manifest can express real plugin needs. diff --git a/notes/research/product-model.md b/notes/research/product-model.md deleted file mode 100644 index f47b188e1..000000000 --- a/notes/research/product-model.md +++ /dev/null @@ -1,179 +0,0 @@ -# Executor Product Model - -Executor is a tool/source discovery and execution layer for agent-callable tools. It is designed around a small set of product primitives that can support multiple product surfaces: a public SDK, a local application, hosted cloud, and self-hosted cloud. - -This document is intentionally high level. It is not an architecture decision record and does not attempt to design every subsystem in detail. Its purpose is to establish the product concepts, vocabulary, and composition model that later research and architecture documents can build on. - -## Goals - -Executor should make it easy for agents and applications to discover, configure, and call tools from many sources. - -The product should not depend on Executor maintaining the largest possible fixed integration library. Happy paths like OpenAPI, GraphQL, and MCP should make common integrations easy, but users should always have a path to add custom tools when an existing integration is missing or incomplete. - -The platform should be built from primitives that are stable enough to support the full product surface and extensible enough that new capabilities can be shipped as plugins instead of always requiring changes to the core. - -The same core concepts should work locally and in the cloud. Local and remote execution may use different runtimes, but the plugin and execution model should feel like the same product architecture. - -## Product Surfaces - -Executor has four primary product surfaces. - -### Core SDK - -The SDK is the foundation that other applications can embed. It should expose the shared product primitives that make Executor useful: tools, sources, scopes, secrets, plugin support, and execution. - -The SDK is not meant to contain every product feature. Features like hosted team management, execution history dashboards, workflow builders, and organization-level policy UI can be built above the SDK or delivered through plugins when they are not required for the primitive system to function. - -The SDK should let product builders use Executor in their own applications. A common use case is an app with agents whose users need to connect their own sources, credentials, and tools. In that case, Executor should handle the hard parts of source configuration, tool discovery, credentials, refresh tokens, and tool invocation. - -### Local Application - -The local application is an open-source way to run Executor on a user's machine. It should provide the local equivalent of the cloud product's core capabilities: available tools and sources, an MCP gateway, configured sources, secrets, scopes, and plugin-based extensibility. - -The local product matters because some users will want a tool gateway that runs near their files, development environment, local network, or personal credentials. It also provides a low-friction way to use Executor without committing to a hosted product. - -### Hosted Cloud - -Hosted cloud is the managed product for teams and companies. It should help organizations scale tool access across many users and agents. - -Cloud adds product needs that are less important locally: organization management, bring-your-own auth, permissions, policy enforcement, auditability, shared secrets, hosted execution, and team-level governance. - -### Self-Hosted Cloud - -Self-hosted cloud should use the same product architecture as hosted cloud where possible. The goal is not to maintain a separate product, but to allow companies to run the cloud architecture themselves when they need stronger control over deployment, data, compliance, or network boundaries. - -## Core Concepts - -Executor is built around a small number of concepts. - -### Tools - -A tool is a callable unit of work. A tool has an ID, a name or function path, and may have input and output schemas. - -Tool IDs are required. Input and output schemas are useful for agents, validation, generated interfaces, and documentation, but should not be treated as mandatory for every possible tool. - -Tools are the lowest-level product object that agents ultimately call. - -### Sources - -A source is a collection of tools. For example, a Vercel source might contain tools for projects, deployments, DNS records, and environment variables. - -Sources are how Executor groups related tools, credentials, configuration, and discovery behavior. A source may come from an imported spec, an MCP server, a first-party integration, a third-party plugin, or custom user code. - -### Secrets - -Secrets are credentials or sensitive values needed by tools and sources. Examples include bearer tokens, API keys, OAuth refresh tokens, and source-specific configuration values. - -Secrets are a product primitive because tool execution often depends on them, and because controlling access to secrets is central to making plugins and untrusted code safe enough to use. - -### Scopes - -Scopes define ownership and visibility for sources, tools, definitions, secrets, and connections. - -Hosts construct an ordered scope stack for an Executor instance. Reads can resolve through the stack, while writes target an explicit scope. This lets personal resources and credentials coexist with shared team or organization resources without requiring every product surface to hard-code the same ownership model. - -The effective tool set an agent sees is the scope-resolved view of available sources and tools. Tools can come from happy-path imports, first-party plugins, third-party plugins, MCP servers, and plugin-provided custom tool support. A custom tool should become part of the same source/tool model rather than living in a separate one-off system. - -### Plugins - -Plugins are a core product concept, not an optional future feature. They are how Executor avoids putting every capability into the core SDK while still letting the platform grow. - -Plugins can extend Executor with new spec types, source loaders, secret storage backends, execution-adjacent features, logging, product integrations, and other capabilities that do not belong directly in the core. - -Plugins should be part of the shared local and cloud architecture. The details of packaging, loading, sandboxing, and runtime behavior should be researched separately, but the product model should assume plugins can work in both environments. - -### Execution - -Execution is the ability to run code that can call tools. This is a major Executor primitive because it unlocks higher-level experiences that can be built outside the core product or through plugins. - -Execution should be designed with untrusted code in mind. Cloud execution is expected to use a cloud isolation primitive such as Cloudflare Dynamic Worker Loaders. Local execution is expected to use a local isolation primitive such as V8 isolates. The exact architecture is a dedicated follow-up topic. - -## Plugin Strategy - -Executor should be extensible by default. When a capability is important but not required for the core primitive system, it should be considered as a plugin candidate. - -This does not mean everything must be a plugin. The core must still define the shared concepts, contracts, and minimal behavior needed for the product to work. Plugins should extend the system without fragmenting the product model. - -### First-Party Plugins - -First-party plugins are maintained by Executor. They provide common, supported capabilities without forcing those capabilities into the core SDK. - -First-party plugin areas include OpenAPI, GraphQL, MCP, custom tools, common secret storage adapters, and other capabilities that are useful across product surfaces. - -First-party plugins may be operationally trusted by Executor, but the product should still prefer the same conceptual plugin model used for third-party plugins. This keeps the architecture consistent and helps prevent first-party extensions from depending on privileged paths that third-party extensions can never use. - -First party plugins should use the same plugin model as third-party plugins. - -### Third-Party Plugins - -Third-party plugins may come from users, vendors, teams, or the community. They should be treated as untrusted by default. - -Installing or loading a plugin should not automatically grant access to all secrets, all tools, the local filesystem, or unrestricted network behavior. Plugins should receive explicit capabilities based on what the user, team, or policy grants. - -Third-party plugins are strategically important because they let Executor cover specialized sources and product needs without waiting for the core team to ship everything. - -## Capability-Based Access - -Executor should lean toward capability-based access for plugins and untrusted execution. - -A plugin-provided capability should only be able to access the secrets, tools, scoped storage, network capabilities, and runtime APIs it has been granted. The user or organization should be able to understand and control those grants. - -This is important across local and cloud. Local plugins should not be allowed to read arbitrary local data just because they were installed. Cloud plugins should not be allowed to read organization-wide secrets or call organization-wide tools just because they exist in the same account. - -The details of grant representation, policy enforcement, user experience, and runtime containment need deeper research. The product-level requirement is that untrusted plugins and code are possible without giving them ambient access to everything Executor knows. - -## Local And Cloud Parity - -Executor should aim for the same conceptual architecture locally and in the cloud. - -The exact runtime may differ. Cloud may use Dynamic Worker Loaders, while local may use V8 isolates. The user-facing and developer-facing model should still be consistent: plugins declare or request capabilities, are granted scoped access, and interact with Executor through stable primitives. - -Local and cloud parity matters because plugins should not need to be rewritten for each product surface. Some differences are inevitable, especially around deployment, persistence, identity, networking, and security boundaries, but the product should avoid creating separate local-only and cloud-only extension ecosystems. - -## What Executor Enables - -Executor is agnostic to the higher-level product built on top of its primitives. - -Workflows, generated UI, custom tools, and agents running one-off scripts through MCP are important outcomes, but they should not be treated as product categories Executor core owns directly. They are examples of what becomes possible when tools, sources, scopes, secrets, plugins, and execution compose well. - -### Custom Tools Plugin - -Custom tools are likely provided by a first-party plugin rather than by Executor core directly. If a source does not have a happy-path integration, an agent or user should be able to write code that calls the target system and add that function to the available tool set through the same plugin and source/tool model. - -This is a major difference from products that compete primarily on integration count. Executor should make missing integrations less fatal because a custom-tools plugin can use the same execution and source/tool primitives as other first-party or third-party plugins. - -### Agent Scripts Over MCP - -An agent connected through MCP should be able to use Executor as a gateway to call available tools. With execution, an agent can also run small scripts that combine tool calls. - -Executor should enable this without requiring the core product to become a full scripting product. The important part is that the primitives support safe tool access and code execution. - -### Generated UI - -Generated UI can use Executor's source/tool discovery and invocation model to build interfaces around tools. A generated interface could call tools in a type-safe or RPC-like style using the available schemas and execution APIs. - -Executor should not assume there is only one generated UI product. It should provide the primitives that make generated UI possible. - -### Workflows - -Workflows can be built as reusable compositions of tools that run on demand, on a schedule, or in response to events. - -Executor should provide the tool, source, secret, scope, plugin, and execution primitives that workflows need, but this document does not define a workflow engine. Workflow design deserves its own research pass. - -## Competitive Positioning - -Executor overlaps with products like Zapier, Composio, Retool, and n8n, but it should not copy any one of them directly. - -Zapier and n8n are strong workflow automation products. Retool is strong at internal applications and operational UI. Composio is closer to Executor's agent-tool integration surface. - -Executor's wedge is that it is a programmable, extensible discovery and execution layer for agent-callable tools. The product should support happy-path integrations, but its main strategic advantage is that users are not blocked when a supported integration does not exist. Plugin-provided custom tools, plugins, and sources can become part of the same source/tool model. - -The product should compete on primitives, extensibility, local/cloud parity, and agent-native tool access rather than only on the number of integrations advertised. - -## Deeper Research Topics - -The concepts in this document are part of the product model, but each needs a deeper follow-up research document before implementation decisions are finalized. - -Important follow-up topics include plugin loading, plugin manifests, local sandboxing, cloud sandboxing, capability grants, secret storage, source importers, MCP gateway behavior, custom tool authoring, workflow composition, generated UI patterns, execution logs, auditability, persistence, policy, auth, and the public SDK shape. - -Those follow-up documents should make architecture decisions where needed. This document should remain the shared product frame those decisions refer back to. diff --git a/notes/source-binding-consolidation.md b/notes/source-binding-consolidation.md deleted file mode 100644 index a78322766..000000000 --- a/notes/source-binding-consolidation.md +++ /dev/null @@ -1,209 +0,0 @@ -# Source Binding Consolidation Notes - -Date: 2026-05-17 -Status: exploring - -## Context - -The branch `codex/fix-openapi-add-flow` shipped a focused cleanup of the -OpenAPI add-source API (see `openapi-add-source-api-cleanup.md`). Looking -across the OpenAPI, GraphQL, and MCP plugins while reviewing that work -turned up a much larger duplication problem. This note captures the -direction we're considering before drafting a real plan. - -## What's duplicated today - -Each of OpenAPI, GraphQL, and MCP independently reimplements the same -source-binding machinery on top of the core `credential_binding` table: - -- A plugin-specific `*SourceBindingInput` / `*SourceBindingRef` type that - is a thin re-skin of core `CredentialBindingInput` / `CredentialBindingRef`. -- A `resolve*SourceBinding` helper that filters bindings by - `slotKey === slot` and picks the innermost-visible binding whose scope - rank ≤ the source's owner-scope rank. Identical across plugins. -- A `list*SourceBindings` helper applying the same scope-ceiling filter. -- A `validate*BindingTarget` helper that re-checks the same outer-scope - rule core's `assertCredentialBindingTargetNotOuter` already enforces. -- A `coreBindingToXxxBinding` adapter that exists only because the - per-plugin Ref type is a re-skin. -- Per-plugin HTTP endpoints: `setSourceBinding`, `listSourceBindings`, - `removeSourceBinding`. Same payloads, same semantics, mounted under - different paths. -- A `canonicalizeCredentialMap` / `canonicalizeAuth` that splits an input - "shape with secret refs" into a stored shape (with binding sentinels) - plus a list of bindings to write. Same algorithm per plugin. -- React glue: each plugin re-wires `secret-header-auth`, - `credential-target-scope`, and `oauth-sign-in` into its own add flow. - -Storage-side, each plugin owns nearly-identical child tables: - -- OpenAPI: `openapi_source_header`, `openapi_source_query_param`, - `openapi_source_spec_fetch_header`, - `openapi_source_spec_fetch_query_param`. -- GraphQL: `graphql_source_header`, `graphql_source_query_param`. -- MCP: `mcp_source_header`, `mcp_source_query_param`. - -All eight tables share the exact same columns: -`(source_id, scope_id, name, kind, text_value, slot_key, prefix)`. -They're discriminated only by which logical compartment the plugin is -modeling. They're written and read wholesale per source (bulk delete + -bulk insert; bulk findMany). No query filters by `name`, `slot_key`, -`kind`, or `prefix`. No join uses them. The Drizzle mirrors in -`apps/cloud` and `apps/local` are the only non-store consumers. - -## The arbitrary-source-types constraint - -We expect to support source types beyond HTTP-ish protocols: CLI -sources (argv + env), database connection strings (templated URIs like -`mysql://user:{{password_slot}}@host/db`), and others we haven't named. - -That changes the design center of any shared abstraction: - -- The HTTP-flavored vocabulary baked into the current shared bits - (`header:`, `query_param:`, `prefix`) doesn't generalize. -- A DB source doesn't have headers; a CLI source doesn't have query - params. The notion of "compartment" is plugin-specific. -- A connection-string source needs interpolation, not just whole-field - substitution. `prefix` is a degenerate one-hole template; argv and - URIs want full templating. - -The only protocol-agnostic primitive is: _"named slot at -(plugin, source, source_scope, scope) resolving to a value of kind -text | secret | connection."_ That's already what core -`credential_binding` provides. - -## Revised consolidation thesis - -Core should own the binding primitive, not just the storage. - -**Core owns:** - -- The `credential_binding` table (today). -- The resolver: given `(pluginId, sourceId, sourceScope, slot, scope)`, - return the innermost-visible binding subject to the source-scope - ceiling. Today this is duplicated per plugin. -- Validation: scope-stack membership, "binding target not outer than - source," secret/connection reachability. The facade already has the - inner checks; the plugin-side `validate*BindingTarget` helpers can go. -- HTTP endpoints for `setBinding` / `listBindings` / `removeBinding`, - parameterised by `pluginId` in the path (or payload). -- A small _slot manifest_ concept: each plugin declares, for a given - source, which slot keys exist, their human-readable labels, expected - value kind, required/optional, and any rendering hints. The UI uses - this to render a credential editor for any plugin without bespoke - React code. - -**Plugins own:** - -- Their source-shape storage. Whatever's plugin-specific (OpenAPI: - spec/baseUrl/operations; GraphQL: endpoint/operations; MCP: - transport/config; DB: connection-string template; CLI: argv/env). - The nearly-identical child tables go away; shape moves to JSON on - the source row or whatever structural columns the plugin actually - needs. -- The wire-format glue: how a resolved slot value becomes an HTTP - header, an MCP transport handshake parameter, a DB connection - string, a CLI argv entry. -- Their slot manifest content. OpenAPI generates it from the parsed - spec; MCP from connect-time metadata; DB from the URI template's - holes. - -**Plugins do not own:** - -- Re-skinned binding types. -- Their own resolver / lister / validator. -- Their own `setSourceBinding` HTTP endpoint. -- Hand-written credential-editor React glue, beyond plugin-specific - manifest hints. - -## What this changes vs the current plan - -The existing `openapi-add-source-api-cleanup.md` proposes deferred -domain wrappers (`setHeaderValue`, `setQueryParamValue`, -`setOAuthClientCredentials`, `setOAuthConnection`). Under the -consolidated model those wrappers stop making sense — they pre-suppose -HTTP-ish compartments. The consumer-facing API becomes "set this slot -on this source," parameterised by a slot key the plugin's manifest -defines. UX affordances like header preset pickers move into the UI -layer, driven by the manifest. - -The plan note's "source shape vs values" split is still right; it just -applies more aggressively. Shape lives in plugin source config (JSON -or columns the plugin actually queries on); values live in core -bindings. - -## On `prefix` - -`prefix` is a UI/UX affordance for the `Authorization: Bearer ` -pattern, generalised to "any prepended literal string." It is stored -in three places (core `ConfiguredCredentialBinding`, -`ScopedSecretCredentialInput`, plugin child-table columns) and -consumed at exactly one site per plugin: - -```ts -resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; -``` - -No escaping, no suffix, no conditional logic. It exists so the secret -itself doesn't have to bake `Bearer ` into the value and so the UI can -render a separate prefix input next to the secret picker. - -It does not generalise to connection strings or argv. The right move -is to drop `prefix` from core entirely and either: - -1. Have it live in the plugin's slot manifest as a pure UI hint, with - the resolver still returning the raw secret value and the plugin - doing the concatenation in its wire-format layer; or -2. Subsume it into a plugin-owned template ("this slot's value is - interpolated into this string"), which is the same idea generalised - to multi-hole shapes. - -Either way the resolver stops doing string concatenation. - -## On the OpenAPI normalized child tables - -In the local-cleanup framing they look wrong because the same four -columns are duplicated four times. In the arbitrary-source-types -framing they look wrong for a deeper reason: they bake HTTP -compartment vocabulary into the schema. They should go away rather -than be consolidated. - -Two reasonable destinations: - -- **JSON on the source row.** Same place OAuth2 config already lives. - Access pattern (read all, replace all) already matches what a JSON - blob gives you. -- **Plugin-owned structural columns where they matter.** If a plugin - truly benefits from a normalized shape table for its own queries, - it can have one; OpenAPI/GraphQL/MCP currently don't. - -## Open questions - -1. **Third-party plugins.** Do we expect plugin authors outside this - repo? That sets the stability bar for the slot manifest contract - and the resolver API. -2. **Templating layer.** Is there one core mini-template language - (`{{slot}}`) that plugins opt into, or is interpolation strictly - plugin-owned? Shared templating gives consistent UX (preview the - resolved value in the UI, redact in logs uniformly); per-plugin - templating gives more freedom but reinvents. -3. **Migration order.** Sketch the core API first and prove it on - OpenAPI as the proving ground, then port GraphQL and MCP in - follow-ups. Each step independently mergeable. -4. **Existing PR.** Land or shelve `codex/fix-openapi-add-flow` as-is - before the bigger consolidation opens; do not swallow the focused - cleanup into the larger refactor. - -## Suggested next steps - -- Close out the existing PR. -- Draft a real plan note that specifies the core API surface (resolver - signature, manifest shape, HTTP endpoints) before touching code. -- Once the shape is agreed, port OpenAPI first: delete the four child - tables, move shape to source-row storage, replace the plugin-level - binding helpers with core calls, replace the plugin-level HTTP group - with a core one. -- Port GraphQL and MCP to match. -- Pick the first arbitrary source type (likely DB connection string or - CLI) as a forcing function to validate the abstraction works beyond - HTTP-ish shapes. diff --git a/notes/source-setup-sdk-flow.md b/notes/source-setup-sdk-flow.md deleted file mode 100644 index ec01c25fd..000000000 --- a/notes/source-setup-sdk-flow.md +++ /dev/null @@ -1,12 +0,0 @@ -# Source setup orchestration - -The web source-add flow currently owns a lot of business logic that agents have -to rediscover through low-level tools: preset lookup, URL-to-endpoint mapping, -endpoint probing, OAuth strategy choice, connection id generation, browser -handoff, credential binding, and final source registration. - -Longer term, consider moving this into an SDK-level source setup service so the -frontend and agent tools share the same state machine. The frontend would render -steps from the service, while agent tools would return the same state with -model-facing `instructions` fields. Keep low-level plugin tools as escape -hatches, but make common preset flows first-class. diff --git a/notes/todo b/notes/todo deleted file mode 100644 index 5f38d97c9..000000000 --- a/notes/todo +++ /dev/null @@ -1,6 +0,0 @@ -Improve agent-facing source configuration scope flow - -- Source add tools now hide raw source placement and resolve the install scope internally, but credential placement still exposes raw scope ids. -- Add a product-level credential target flow for agents, probably "personal" vs "organization", that maps to the right credential scope internally. -- Keep the main-branch separation intact: source tools declare shared source config and credential slots; credential tools create secrets/connections and bind them at the selected credential scope. -- Agent descriptions should explain when a credential target choice is needed and when local single-scope executors can default automatically.