import type { Context, Effect, Layer } from "effect";import type { HttpApiGroup } from "effect/unstable/httpapi";import type { DBAdapter, DBSchema, StorageFailure, TypedAdapter,} from "@executor-js/storage-core"; import type { PluginBlobStore } from "./blob";import type { ConnectionProvider, ConnectionRef, ConnectionRefreshError, CreateConnectionInput, UpdateConnectionTokensInput,} from "./connections";import type { DefinitionsInput, SourceInput, ToolAnnotations, ToolRow,} from "./core-schema";import type { SourceDetectionResult } from "./types";import type { ElicitationDeclinedError, ElicitationHandler, ElicitationRequest, ElicitationResponse,} from "./elicitation";import type { ConnectionNotFoundError, ConnectionProviderNotRegisteredError, ConnectionReauthRequiredError, ConnectionRefreshNotSupportedError, SecretOwnedByConnectionError,} from "./errors";import type { OAuthService } from "./oauth";import type { Scope } from "./scope";import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; // ---------------------------------------------------------------------------// StorageDeps — backing passed to a plugin's `storage` factory. The only// place a plugin ever sees storage; `PluginCtx` does not carry it. The// `adapter` field is a `TypedAdapter<TSchema>` view narrowed by the// plugin's own declared `schema` — plugins never import or construct// a typed adapter themselves, the executor infers TSchema from the// `schema` field on their spec and hands back a typed view.//// Plugins with no schema (secret-provider-only plugins, etc.) get a// bare `DBAdapter` they can ignore.// --------------------------------------------------------------------------- export interface StorageDeps<TSchema extends DBSchema | undefined = undefined> { /** * Precedence-ordered scope stack visible to this executor. Innermost * first. Reads on scoped tables walk every scope; writes require the * plugin to name a target scope explicitly (via `scope_id` on the * adapter payload, via `options.scope` on the blob store). */ readonly scopes: readonly Scope[]; /** * Plugin-facing typed adapter. Failures surface as raw `StorageFailure` * (`StorageError` | `UniqueViolationError`). Plugins can * `catchTag("UniqueViolationError", …)` to translate to their own * user-facing errors. `StorageError` bubbles up; the HTTP edge (see * `@executor-js/api` `withCapture`) is the one place that * translates it to the opaque `InternalError({ traceId })`. */ readonly adapter: TSchema extends DBSchema ? TypedAdapter<TSchema, StorageFailure> : DBAdapter; readonly blobs: PluginBlobStore;} // ---------------------------------------------------------------------------// defineSchema — sugar around `as const satisfies DBSchema`. Preserves// literal types via the `const` type parameter modifier so plugins can// just write `const mySchema = defineSchema({ ... })` without annotation// ceremony.// --------------------------------------------------------------------------- export const defineSchema = <const S extends DBSchema>(schema: S): S => schema; // ---------------------------------------------------------------------------// Elicit — suspends the fiber, calls the invoke-time elicitation// handler, resumes with the user's response. Available on both static// tool handlers and dynamic `invokeTool` handlers. Threaded through// the executor from `createExecutor({ onElicitation })`.// --------------------------------------------------------------------------- export type Elicit = ( request: ElicitationRequest,) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>; // ---------------------------------------------------------------------------// PluginCtx — threaded into every extension method, static tool handler,// and dynamic tool handler. No raw adapter, no raw blobs. Core writes// go through `core.sources.register` / `core.definitions.register`.// --------------------------------------------------------------------------- export interface PluginCtx<TStore = unknown> { /** * Precedence-ordered scope stack visible to this executor. Innermost * first. Plugins that write scoped rows must pick an element of * `scopes` as the `scope`/`scope_id` they stamp; reads through the * adapter or `ctx.secrets` automatically fall through the stack. */ readonly scopes: readonly Scope[]; readonly storage: TStore; readonly core: { readonly sources: { readonly register: ( input: SourceInput, ) => Effect.Effect<void, StorageFailure>; readonly unregister: ( sourceId: string, ) => Effect.Effect<void, StorageFailure>; readonly update: (input: { readonly id: string; readonly scope: string; readonly name?: string; readonly url?: string | null; }) => Effect.Effect<void, StorageFailure>; }; /** Register shared JSON-schema `$defs` for a source. Tool * input/output schemas registered via `sources.register` can carry * `$ref: "#/$defs/X"` pointers; `executor.tools.schema(toolId)` * attaches matching defs to the returned schema. Call inside the * same `ctx.transaction` as `sources.register` for atomicity. * Replaces any existing defs for the given sourceId. */ readonly definitions: { readonly register: ( input: DefinitionsInput, ) => Effect.Effect<void, StorageFailure>; }; }; readonly secrets: { readonly get: ( id: string, ) => Effect.Effect<string | null, SecretOwnedByConnectionError | StorageFailure>; /** List user-visible secrets. Connection-owned secrets (rows with * `owned_by_connection_id` set) are filtered out so they don't * clutter the UI — users see the Connection instead. */ readonly list: () => Effect.Effect< readonly { readonly id: string; readonly name: string; readonly provider: string }[], StorageFailure >; /** Write a secret value through a provider. Used by plugins that * mint secrets on behalf of the user (OAuth2 token storage, * interactive onboarding flows). Normally writes go through * `executor.secrets.set` on the host surface, but OAuth2 refresh * and one-shot token capture from plugin-owned flows need it here * too. Same routing rules as the host-level setter. */ readonly set: ( input: SetSecretInput, ) => Effect.Effect<SecretRef, StorageFailure>; /** Delete a secret from its pinned provider and the core table. * Rejects with `SecretOwnedByConnectionError` if the row is owned * by a connection — callers must go through `connections.remove` * to drop the whole sign-in. */ readonly remove: ( id: string, ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>; }; /** Connections — product-level sign-in state. Owns backing secret * rows via `secret.owned_by_connection_id`. Plugins call * `connections.accessToken(id)` at invoke time to get a guaranteed- * fresh token (the SDK handles refresh via the registered provider * keyed by `connection.provider`). */ readonly connections: { readonly get: ( id: string, ) => Effect.Effect<ConnectionRef | null, StorageFailure>; readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>; readonly create: ( input: CreateConnectionInput, ) => Effect.Effect< ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure >; readonly updateTokens: ( input: UpdateConnectionTokensInput, ) => Effect.Effect< ConnectionRef, ConnectionNotFoundError | StorageFailure >; readonly setIdentityLabel: ( id: string, label: string | null, ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; /** Get a guaranteed-fresh access token. Calls the provider's * `refresh` handler if `expires_at` is in the past / within the * refresh skew window. */ readonly accessToken: ( id: string, ) => Effect.Effect< string, | ConnectionNotFoundError | ConnectionProviderNotRegisteredError | ConnectionRefreshNotSupportedError | ConnectionReauthRequiredError | ConnectionRefreshError | StorageFailure >; readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; }; /** Shared OAuth service. Plugins use this to probe/start/complete OAuth * flows; invocation should still resolve tokens via `connections.accessToken`. */ readonly oauth: OAuthService; /** Run `effect` inside a database transaction. Wraps the underlying * adapter's transaction method. Use this in extension methods that * need atomicity across plugin storage writes AND core source/tool * registration. */ readonly transaction: <A, E>( effect: Effect.Effect<A, E>, ) => Effect.Effect<A, E | StorageFailure>;} // ---------------------------------------------------------------------------// Static tool / source declarations. Pure data + handlers declared at// plugin-definition time.//// Importantly, `StaticToolDecl.handler` does NOT reference TExtension.// If it did, the nested generic would break inference for the whole// PluginSpec (TS would fall back to the `object` constraint on TExtension).// `self: NoInfer<TExtension>` lives on `staticSources` one level up// instead, and plugin authors close over it via the arrow-function// closure when they write their handler.// --------------------------------------------------------------------------- export interface StaticToolHandlerInput<TStore = unknown> { readonly ctx: PluginCtx<TStore>; readonly args: unknown; /** Suspend the fiber to request user input. The handler passed to * `createExecutor({ onElicitation })` is called. */ readonly elicit: Elicit;} export interface StaticToolDecl<TStore = unknown> { readonly name: string; readonly description: string; readonly inputSchema?: unknown; readonly outputSchema?: unknown; /** Default-policy annotations — `requiresApproval`, `approvalDescription`, * `mayElicit`. Enforced by the executor before the handler runs. * Inline because static tools have no plugin storage to resolve from; * the plugin author literally writes this at definition time. */ readonly annotations?: ToolAnnotations; readonly handler: ( input: StaticToolHandlerInput<TStore>, ) => Effect.Effect<unknown, unknown>;} export interface StaticSourceDecl<TStore = unknown> { readonly id: string; readonly kind: string; readonly name: string; readonly url?: string; /** Static sources default to `canRemove: false` — they represent * plugin-provided control surfaces and shouldn't be user-removable. * Override only if you really want that. */ readonly canRemove?: boolean; readonly canRefresh?: boolean; readonly canEdit?: boolean; readonly tools: readonly StaticToolDecl<TStore>[];} // ---------------------------------------------------------------------------// Dynamic invoke / source lifecycle inputs.// --------------------------------------------------------------------------- export interface InvokeToolInput<TStore = unknown> { readonly ctx: PluginCtx<TStore>; /** Already-loaded tool row. Plugin doesn't need to re-fetch or parse * the tool id. Carries source_id, name, input/output schemas, * annotations. */ readonly toolRow: ToolRow; readonly args: unknown; /** Elicitation handle for plugins that need mid-invocation user input * (onepassword auth prompt, interactive MCP tools, etc.). */ readonly elicit: Elicit;} export interface SourceLifecycleInput<TStore = unknown> { readonly ctx: PluginCtx<TStore>; readonly sourceId: string; /** * Scope of the source row being removed/refreshed — resolved by the * SDK's `sources.remove` / `sources.refresh` via innermost-wins lookup * across the executor's scope stack. Plugins that own a side table * keyed by (id, scope_id) must pin their own cleanup to this scope; * relying on the scoped adapter's `scope_id IN (stack)` fall-through * would widen the mutation across the whole stack and wipe a * shadowed outer-scope row. */ readonly scope: string;} // ---------------------------------------------------------------------------// PluginSpec — what a `definePlugin(factory)` call returns.// --------------------------------------------------------------------------- // Defaults are `any` for slots that surface in contravariant positions// (storage/extension callbacks consume `TStore`/`TSchema`; `staticSources`// closes over `TExtension` via `NoInfer`). `any` is bivariant, so// `Plugin<string>` is a structural supertype of every concrete plugin// — `AnyPlugin = Plugin<string>` keeps the generic explosion contained// to this single declaration. Concrete specs ignore the defaults; TS// infers each slot from the literal returned by the author factory.//// eslint-disable-next-line @typescript-eslint/no-explicit-anyexport interface PluginSpec< TId extends string = string, // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtension extends object = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TStore = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TSchema extends DBSchema | undefined = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtensionService extends Context.Service<any, any> | undefined = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any THandlersLayer extends Layer.Layer<any, any, any> = any, TGroup extends HttpApiGroup.Any = HttpApiGroup.Any,> { readonly id: TId; /** npm package name. The Vite plugin uses this to derive the * `./client` import path for the frontend bundle (so the same * `executor.config.ts` drives both server and client) — `${packageName}/client` * is what gets bundled. The author writes the same string they * publish to npm; no transforms, no scope conventions. Required for * plugins that ship a `./client` entry; can be omitted for SDK-only * plugins (no client bundle = nothing to resolve). */ readonly packageName?: string; /** Plugin-declared schema. Merged with coreSchema and other plugins' * schemas at executor startup via `collectSchemas`. The type flows * into the `storage` factory's `deps.adapter` as a `TypedAdapter<TSchema>` * so plugins get narrowed model names + typed rows for free. */ readonly schema?: TSchema; /** Build the plugin's typed store from backing. `deps.adapter` is * already narrowed to this plugin's schema; `deps.blobs` is already * scoped to the plugin id so key collisions across plugins are * structurally impossible. */ readonly storage: (deps: StorageDeps<TSchema>) => TStore; /** Build the plugin's extension API. The returned object becomes * `executor[plugin.id]` and is also the `self` passed to * `staticSources`. Field order matters: `extension` MUST appear * before `staticSources` so TS infers TExtension from this * factory's return BEFORE type-checking `self: NoInfer<TExtension>`. */ readonly extension?: (ctx: PluginCtx<TStore>) => TExtension; /** Static sources contributed by this plugin with inline tool * handlers. Lives entirely in memory — no DB writes at startup. * Handlers close over `self` via the closure, so a control tool * that delegates to the plugin's real API is a one-liner: * `({ args }) => self.addSpec(args)`. */ readonly staticSources?: ( self: NoInfer<TExtension>, ) => readonly StaticSourceDecl<TStore>[]; /** HttpApiGroup contributed by this plugin. Composed into the host's * `HttpApi` via the `addGroup` helper at runtime. The host mounts * the group at `/_executor/plugins/{id}/...` (or wherever the * plugin declares its base path) with the host's auth + scope * middleware applied. Endpoints automatically appear in the * executor OpenAPI doc and the typed reactive client. * * TGroup is inferred from the plugin's own group declaration so the * precise group identity flows through `composePluginApi(plugins)` — * the host's typed `HttpApi<"executor", CoreGroups | PluginGroups>` * is derived from the plugin tuple alone, with no per-plugin Group * imports at the host. Per-endpoint typing already lives inside the * plugin — its `handlers` Layer is built against its own bundled * `HttpApi.make("foo").add(FooApi)` for full `.handle("name", ...)` * inference, and its client imports the same group directly. */ readonly routes?: () => TGroup; /** Handlers Layer for this plugin's group. Built by the plugin against * its own bundled API for full type safety on `.handle("name", ...)`, * composes into the host's runtime `FullApi` because * `HttpApiBuilder.group` keys the layer by group identity, not by the * surrounding API. * * Late-binding: 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])` * (see `composePluginHandlers`) * - cloud: per-request via `Effect.provideService(extensionService, * requestExecutor[id])` in the auth middleware * * The Layer's channels are typed `any` because `Layer<RIn, E, ROut>`'s * `ROut` is contravariant — the host accepts any layer here and merges * them; per-plugin requirements flow through the merge. */ readonly handlers?: () => THandlersLayer; /** Service tag the plugin's `handlers` layer requires. Set by plugins * whose handlers consume their extension via a `Context.Service` tag * (the established pattern: `*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. * * Inferred via the `TExtensionService` generic so the per-plugin * Service class identity propagates through `composePluginHandlers`, * `composePluginHandlerLayer`, and `providePluginExtensions` — * cloud's per-request middleware needs the precise tag for layer * satisfaction. */ readonly extensionService?: TExtensionService; /** Invoke a dynamic tool. Called when the executor's static-handler * map doesn't have the toolId. The plugin reads its own enrichment * via `ctx.storage` and returns the result. Optional — plugins with * only static tools can omit it. */ readonly invokeTool?: ( input: InvokeToolInput<TStore>, ) => Effect.Effect<unknown, unknown>; /** Bulk resolve annotations (requiresApproval, approvalDescription, * mayElicit) for a set of tool rows under a single source. Called * by the executor: * - at invoke time with a single-element `toolRows` array, to * enforce approval on the about-to-run tool * - at list time with every dynamic tool row under each source, * grouped by source_id, to populate `Tool.annotations` for UI * * The expected implementation for most plugins is: read plugin * storage once for the given source/rows, derive annotations from * the same data that was used to build the tool (HTTP method + * path for openapi, introspection kind for graphql, etc.), return * a map keyed by tool id. * * Omit if the plugin has no annotations to contribute — executor * treats tools from that plugin as auto-approved with no * elicitation. */ readonly resolveAnnotations?: (input: { readonly ctx: PluginCtx<TStore>; readonly sourceId: string; readonly toolRows: readonly ToolRow[]; }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>; /** Called when `executor.sources.remove(id)` targets a source owned * by this plugin. Plugin-side cleanup only; the executor deletes * the core source/tool rows after this callback returns, inside * the same transaction. */ readonly removeSource?: ( input: SourceLifecycleInput<TStore>, ) => Effect.Effect<void, unknown>; readonly refreshSource?: ( input: SourceLifecycleInput<TStore>, ) => Effect.Effect<void, unknown>; /** URL autodetection hook. When the user pastes a URL in the * onboarding UI, `executor.sources.detect(url)` fans out to every * plugin's `detect`. Return a `SourceDetectionResult` if you * recognize the URL, `null` otherwise. Implementations should be * defensive — swallow fetch errors and return null rather than * throwing. First high-confidence match wins. */ readonly detect?: (input: { readonly ctx: PluginCtx<TStore>; readonly url: string; }) => Effect.Effect<SourceDetectionResult | null, unknown>; /** Secret providers contributed by this plugin. Either a static * array, a function of ctx (for providers that need per-instance * state like the keychain's scope-derived service name), or a * function returning an Effect so plugins can probe for backend * availability at startup and register conditionally. Called once * at executor startup after `storage` and `extension` have been * built. */ readonly secretProviders?: | readonly SecretProvider[] | ((ctx: PluginCtx<TStore>) => readonly SecretProvider[]) | (( ctx: PluginCtx<TStore>, ) => Effect.Effect<readonly SecretProvider[]>); /** Connection providers contributed by this plugin. Same registration * shape as `secretProviders`. Each provider's `key` is what * `connection.provider` references in the core table; the `refresh` * handler is the SDK's single entry point for token lifecycle — * plugins don't run their own refresh loops anymore. */ readonly connectionProviders?: | readonly ConnectionProvider[] | ((ctx: PluginCtx<TStore>) => readonly ConnectionProvider[]) | (( ctx: PluginCtx<TStore>, ) => Effect.Effect<readonly ConnectionProvider[]>); readonly close?: () => Effect.Effect<void, unknown>;} Lines 1–500 of 636. Load more
import type { Context, Effect, Layer } from "effect"; import type { HttpApiGroup } from "effect/unstable/httpapi"; import type { DBAdapter, DBSchema, StorageFailure, TypedAdapter, } from "@executor-js/storage-core"; import type { PluginBlobStore } from "./blob"; import type { ConnectionProvider, ConnectionRef, ConnectionRefreshError, CreateConnectionInput, UpdateConnectionTokensInput, } from "./connections"; import type { DefinitionsInput, SourceInput, ToolAnnotations, ToolRow, } from "./core-schema"; import type { SourceDetectionResult } from "./types"; import type { ElicitationDeclinedError, ElicitationHandler, ElicitationRequest, ElicitationResponse, } from "./elicitation"; import type { ConnectionNotFoundError, ConnectionProviderNotRegisteredError, ConnectionReauthRequiredError, ConnectionRefreshNotSupportedError, SecretOwnedByConnectionError, } from "./errors"; import type { OAuthService } from "./oauth"; import type { Scope } from "./scope"; import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; // --------------------------------------------------------------------------- // StorageDeps — backing passed to a plugin's `storage` factory. The only // place a plugin ever sees storage; `PluginCtx` does not carry it. The // `adapter` field is a `TypedAdapter<TSchema>` view narrowed by the // plugin's own declared `schema` — plugins never import or construct // a typed adapter themselves, the executor infers TSchema from the // `schema` field on their spec and hands back a typed view. // // Plugins with no schema (secret-provider-only plugins, etc.) get a // bare `DBAdapter` they can ignore. // --------------------------------------------------------------------------- export interface StorageDeps<TSchema extends DBSchema | undefined = undefined> { /** * Precedence-ordered scope stack visible to this executor. Innermost * first. Reads on scoped tables walk every scope; writes require the * plugin to name a target scope explicitly (via `scope_id` on the * adapter payload, via `options.scope` on the blob store). */ readonly scopes: readonly Scope[]; /** * Plugin-facing typed adapter. Failures surface as raw `StorageFailure` * (`StorageError` | `UniqueViolationError`). Plugins can * `catchTag("UniqueViolationError", …)` to translate to their own * user-facing errors. `StorageError` bubbles up; the HTTP edge (see * `@executor-js/api` `withCapture`) is the one place that * translates it to the opaque `InternalError({ traceId })`. */ readonly adapter: TSchema extends DBSchema ? TypedAdapter<TSchema, StorageFailure> : DBAdapter; readonly blobs: PluginBlobStore; } // --------------------------------------------------------------------------- // defineSchema — sugar around `as const satisfies DBSchema`. Preserves // literal types via the `const` type parameter modifier so plugins can // just write `const mySchema = defineSchema({ ... })` without annotation // ceremony. // --------------------------------------------------------------------------- export const defineSchema = <const S extends DBSchema>(schema: S): S => schema; // --------------------------------------------------------------------------- // Elicit — suspends the fiber, calls the invoke-time elicitation // handler, resumes with the user's response. Available on both static // tool handlers and dynamic `invokeTool` handlers. Threaded through // the executor from `createExecutor({ onElicitation })`. // --------------------------------------------------------------------------- export type Elicit = ( request: ElicitationRequest, ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>; // --------------------------------------------------------------------------- // PluginCtx — threaded into every extension method, static tool handler, // and dynamic tool handler. No raw adapter, no raw blobs. Core writes // go through `core.sources.register` / `core.definitions.register`. // --------------------------------------------------------------------------- export interface PluginCtx<TStore = unknown> { /** * Precedence-ordered scope stack visible to this executor. Innermost * first. Plugins that write scoped rows must pick an element of * `scopes` as the `scope`/`scope_id` they stamp; reads through the * adapter or `ctx.secrets` automatically fall through the stack. */ readonly scopes: readonly Scope[]; readonly storage: TStore; readonly core: { readonly sources: { readonly register: ( input: SourceInput, ) => Effect.Effect<void, StorageFailure>; readonly unregister: ( sourceId: string, ) => Effect.Effect<void, StorageFailure>; readonly update: (input: { readonly id: string; readonly scope: string; readonly name?: string; readonly url?: string | null; }) => Effect.Effect<void, StorageFailure>; }; /** Register shared JSON-schema `$defs` for a source. Tool * input/output schemas registered via `sources.register` can carry * `$ref: "#/$defs/X"` pointers; `executor.tools.schema(toolId)` * attaches matching defs to the returned schema. Call inside the * same `ctx.transaction` as `sources.register` for atomicity. * Replaces any existing defs for the given sourceId. */ readonly definitions: { readonly register: ( input: DefinitionsInput, ) => Effect.Effect<void, StorageFailure>; }; }; readonly secrets: { readonly get: ( id: string, ) => Effect.Effect<string | null, SecretOwnedByConnectionError | StorageFailure>; /** List user-visible secrets. Connection-owned secrets (rows with * `owned_by_connection_id` set) are filtered out so they don't * clutter the UI — users see the Connection instead. */ readonly list: () => Effect.Effect< readonly { readonly id: string; readonly name: string; readonly provider: string }[], StorageFailure >; /** Write a secret value through a provider. Used by plugins that * mint secrets on behalf of the user (OAuth2 token storage, * interactive onboarding flows). Normally writes go through * `executor.secrets.set` on the host surface, but OAuth2 refresh * and one-shot token capture from plugin-owned flows need it here * too. Same routing rules as the host-level setter. */ readonly set: ( input: SetSecretInput, ) => Effect.Effect<SecretRef, StorageFailure>; /** Delete a secret from its pinned provider and the core table. * Rejects with `SecretOwnedByConnectionError` if the row is owned * by a connection — callers must go through `connections.remove` * to drop the whole sign-in. */ readonly remove: ( id: string, ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>; }; /** Connections — product-level sign-in state. Owns backing secret * rows via `secret.owned_by_connection_id`. Plugins call * `connections.accessToken(id)` at invoke time to get a guaranteed- * fresh token (the SDK handles refresh via the registered provider * keyed by `connection.provider`). */ readonly connections: { readonly get: ( id: string, ) => Effect.Effect<ConnectionRef | null, StorageFailure>; readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>; readonly create: ( input: CreateConnectionInput, ) => Effect.Effect< ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure >; readonly updateTokens: ( input: UpdateConnectionTokensInput, ) => Effect.Effect< ConnectionRef, ConnectionNotFoundError | StorageFailure >; readonly setIdentityLabel: ( id: string, label: string | null, ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; /** Get a guaranteed-fresh access token. Calls the provider's * `refresh` handler if `expires_at` is in the past / within the * refresh skew window. */ readonly accessToken: ( id: string, ) => Effect.Effect< string, | ConnectionNotFoundError | ConnectionProviderNotRegisteredError | ConnectionRefreshNotSupportedError | ConnectionReauthRequiredError | ConnectionRefreshError | StorageFailure >; readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; }; /** Shared OAuth service. Plugins use this to probe/start/complete OAuth * flows; invocation should still resolve tokens via `connections.accessToken`. */ readonly oauth: OAuthService; /** Run `effect` inside a database transaction. Wraps the underlying * adapter's transaction method. Use this in extension methods that * need atomicity across plugin storage writes AND core source/tool * registration. */ readonly transaction: <A, E>( effect: Effect.Effect<A, E>, ) => Effect.Effect<A, E | StorageFailure>; } // --------------------------------------------------------------------------- // Static tool / source declarations. Pure data + handlers declared at // plugin-definition time. // // Importantly, `StaticToolDecl.handler` does NOT reference TExtension. // If it did, the nested generic would break inference for the whole // PluginSpec (TS would fall back to the `object` constraint on TExtension). // `self: NoInfer<TExtension>` lives on `staticSources` one level up // instead, and plugin authors close over it via the arrow-function // closure when they write their handler. // --------------------------------------------------------------------------- export interface StaticToolHandlerInput<TStore = unknown> { readonly ctx: PluginCtx<TStore>; readonly args: unknown; /** Suspend the fiber to request user input. The handler passed to * `createExecutor({ onElicitation })` is called. */ readonly elicit: Elicit; } export interface StaticToolDecl<TStore = unknown> { readonly name: string; readonly description: string; readonly inputSchema?: unknown; readonly outputSchema?: unknown; /** Default-policy annotations — `requiresApproval`, `approvalDescription`, * `mayElicit`. Enforced by the executor before the handler runs. * Inline because static tools have no plugin storage to resolve from; * the plugin author literally writes this at definition time. */ readonly annotations?: ToolAnnotations; readonly handler: ( input: StaticToolHandlerInput<TStore>, ) => Effect.Effect<unknown, unknown>; } export interface StaticSourceDecl<TStore = unknown> { readonly id: string; readonly kind: string; readonly name: string; readonly url?: string; /** Static sources default to `canRemove: false` — they represent * plugin-provided control surfaces and shouldn't be user-removable. * Override only if you really want that. */ readonly canRemove?: boolean; readonly canRefresh?: boolean; readonly canEdit?: boolean; readonly tools: readonly StaticToolDecl<TStore>[]; } // --------------------------------------------------------------------------- // Dynamic invoke / source lifecycle inputs. // --------------------------------------------------------------------------- export interface InvokeToolInput<TStore = unknown> { readonly ctx: PluginCtx<TStore>; /** Already-loaded tool row. Plugin doesn't need to re-fetch or parse * the tool id. Carries source_id, name, input/output schemas, * annotations. */ readonly toolRow: ToolRow; readonly args: unknown; /** Elicitation handle for plugins that need mid-invocation user input * (onepassword auth prompt, interactive MCP tools, etc.). */ readonly elicit: Elicit; } export interface SourceLifecycleInput<TStore = unknown> { readonly ctx: PluginCtx<TStore>; readonly sourceId: string; /** * Scope of the source row being removed/refreshed — resolved by the * SDK's `sources.remove` / `sources.refresh` via innermost-wins lookup * across the executor's scope stack. Plugins that own a side table * keyed by (id, scope_id) must pin their own cleanup to this scope; * relying on the scoped adapter's `scope_id IN (stack)` fall-through * would widen the mutation across the whole stack and wipe a * shadowed outer-scope row. */ readonly scope: string; } // --------------------------------------------------------------------------- // PluginSpec — what a `definePlugin(factory)` call returns. // --------------------------------------------------------------------------- // Defaults are `any` for slots that surface in contravariant positions // (storage/extension callbacks consume `TStore`/`TSchema`; `staticSources` // closes over `TExtension` via `NoInfer`). `any` is bivariant, so // `Plugin<string>` is a structural supertype of every concrete plugin // — `AnyPlugin = Plugin<string>` keeps the generic explosion contained // to this single declaration. Concrete specs ignore the defaults; TS // infers each slot from the literal returned by the author factory. // // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface PluginSpec< TId extends string = string, // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtension extends object = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TStore = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TSchema extends DBSchema | undefined = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtensionService extends Context.Service<any, any> | undefined = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any THandlersLayer extends Layer.Layer<any, any, any> = any, TGroup extends HttpApiGroup.Any = HttpApiGroup.Any, > { readonly id: TId; /** npm package name. The Vite plugin uses this to derive the * `./client` import path for the frontend bundle (so the same * `executor.config.ts` drives both server and client) — `${packageName}/client` * is what gets bundled. The author writes the same string they * publish to npm; no transforms, no scope conventions. Required for * plugins that ship a `./client` entry; can be omitted for SDK-only * plugins (no client bundle = nothing to resolve). */ readonly packageName?: string; /** Plugin-declared schema. Merged with coreSchema and other plugins' * schemas at executor startup via `collectSchemas`. The type flows * into the `storage` factory's `deps.adapter` as a `TypedAdapter<TSchema>` * so plugins get narrowed model names + typed rows for free. */ readonly schema?: TSchema; /** Build the plugin's typed store from backing. `deps.adapter` is * already narrowed to this plugin's schema; `deps.blobs` is already * scoped to the plugin id so key collisions across plugins are * structurally impossible. */ readonly storage: (deps: StorageDeps<TSchema>) => TStore; /** Build the plugin's extension API. The returned object becomes * `executor[plugin.id]` and is also the `self` passed to * `staticSources`. Field order matters: `extension` MUST appear * before `staticSources` so TS infers TExtension from this * factory's return BEFORE type-checking `self: NoInfer<TExtension>`. */ readonly extension?: (ctx: PluginCtx<TStore>) => TExtension; /** Static sources contributed by this plugin with inline tool * handlers. Lives entirely in memory — no DB writes at startup. * Handlers close over `self` via the closure, so a control tool * that delegates to the plugin's real API is a one-liner: * `({ args }) => self.addSpec(args)`. */ readonly staticSources?: ( self: NoInfer<TExtension>, ) => readonly StaticSourceDecl<TStore>[]; /** HttpApiGroup contributed by this plugin. Composed into the host's * `HttpApi` via the `addGroup` helper at runtime. The host mounts * the group at `/_executor/plugins/{id}/...` (or wherever the * plugin declares its base path) with the host's auth + scope * middleware applied. Endpoints automatically appear in the * executor OpenAPI doc and the typed reactive client. * * TGroup is inferred from the plugin's own group declaration so the * precise group identity flows through `composePluginApi(plugins)` — * the host's typed `HttpApi<"executor", CoreGroups | PluginGroups>` * is derived from the plugin tuple alone, with no per-plugin Group * imports at the host. Per-endpoint typing already lives inside the * plugin — its `handlers` Layer is built against its own bundled * `HttpApi.make("foo").add(FooApi)` for full `.handle("name", ...)` * inference, and its client imports the same group directly. */ readonly routes?: () => TGroup; /** Handlers Layer for this plugin's group. Built by the plugin against * its own bundled API for full type safety on `.handle("name", ...)`, * composes into the host's runtime `FullApi` because * `HttpApiBuilder.group` keys the layer by group identity, not by the * surrounding API. * * Late-binding: 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])` * (see `composePluginHandlers`) * - cloud: per-request via `Effect.provideService(extensionService, * requestExecutor[id])` in the auth middleware * * The Layer's channels are typed `any` because `Layer<RIn, E, ROut>`'s * `ROut` is contravariant — the host accepts any layer here and merges * them; per-plugin requirements flow through the merge. */ readonly handlers?: () => THandlersLayer; /** Service tag the plugin's `handlers` layer requires. Set by plugins * whose handlers consume their extension via a `Context.Service` tag * (the established pattern: `*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. * * Inferred via the `TExtensionService` generic so the per-plugin * Service class identity propagates through `composePluginHandlers`, * `composePluginHandlerLayer`, and `providePluginExtensions` — * cloud's per-request middleware needs the precise tag for layer * satisfaction. */ readonly extensionService?: TExtensionService; /** Invoke a dynamic tool. Called when the executor's static-handler * map doesn't have the toolId. The plugin reads its own enrichment * via `ctx.storage` and returns the result. Optional — plugins with * only static tools can omit it. */ readonly invokeTool?: ( input: InvokeToolInput<TStore>, ) => Effect.Effect<unknown, unknown>; /** Bulk resolve annotations (requiresApproval, approvalDescription, * mayElicit) for a set of tool rows under a single source. Called * by the executor: * - at invoke time with a single-element `toolRows` array, to * enforce approval on the about-to-run tool * - at list time with every dynamic tool row under each source, * grouped by source_id, to populate `Tool.annotations` for UI * * The expected implementation for most plugins is: read plugin * storage once for the given source/rows, derive annotations from * the same data that was used to build the tool (HTTP method + * path for openapi, introspection kind for graphql, etc.), return * a map keyed by tool id. * * Omit if the plugin has no annotations to contribute — executor * treats tools from that plugin as auto-approved with no * elicitation. */ readonly resolveAnnotations?: (input: { readonly ctx: PluginCtx<TStore>; readonly sourceId: string; readonly toolRows: readonly ToolRow[]; }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>; /** Called when `executor.sources.remove(id)` targets a source owned * by this plugin. Plugin-side cleanup only; the executor deletes * the core source/tool rows after this callback returns, inside * the same transaction. */ readonly removeSource?: ( input: SourceLifecycleInput<TStore>, ) => Effect.Effect<void, unknown>; readonly refreshSource?: ( input: SourceLifecycleInput<TStore>, ) => Effect.Effect<void, unknown>; /** URL autodetection hook. When the user pastes a URL in the * onboarding UI, `executor.sources.detect(url)` fans out to every * plugin's `detect`. Return a `SourceDetectionResult` if you * recognize the URL, `null` otherwise. Implementations should be * defensive — swallow fetch errors and return null rather than * throwing. First high-confidence match wins. */ readonly detect?: (input: { readonly ctx: PluginCtx<TStore>; readonly url: string; }) => Effect.Effect<SourceDetectionResult | null, unknown>; /** Secret providers contributed by this plugin. Either a static * array, a function of ctx (for providers that need per-instance * state like the keychain's scope-derived service name), or a * function returning an Effect so plugins can probe for backend * availability at startup and register conditionally. Called once * at executor startup after `storage` and `extension` have been * built. */ readonly secretProviders?: | readonly SecretProvider[] | ((ctx: PluginCtx<TStore>) => readonly SecretProvider[]) | (( ctx: PluginCtx<TStore>, ) => Effect.Effect<readonly SecretProvider[]>); /** Connection providers contributed by this plugin. Same registration * shape as `secretProviders`. Each provider's `key` is what * `connection.provider` references in the core table; the `refresh` * handler is the SDK's single entry point for token lifecycle — * plugins don't run their own refresh loops anymore. */ readonly connectionProviders?: | readonly ConnectionProvider[] | ((ctx: PluginCtx<TStore>) => readonly ConnectionProvider[]) | (( ctx: PluginCtx<TStore>, ) => Effect.Effect<readonly ConnectionProvider[]>); readonly close?: () => Effect.Effect<void, unknown>; }