diff --git a/packages/core/sdk/src/executor-types.ts b/packages/core/sdk/src/executor-types.ts new file mode 100644 index 000000000..fa998dcbc --- /dev/null +++ b/packages/core/sdk/src/executor-types.ts @@ -0,0 +1,279 @@ +import type { Duration, Effect, Layer } from "effect"; +import type { HttpClient } from "effect/unstable/http"; + +import type { + ConnectionRef, + ConnectionRefreshError, + CreateConnectionInput, + RemoveConnectionInput, + UpdateConnectionTokensInput, +} from "./connections"; +import type { CredentialBindingsFacade } from "./credential-bindings"; +import type { ElicitationDeclinedError, ElicitationHandler } from "./elicitation"; +import type { + ConnectionInUseError, + ConnectionNotFoundError, + ConnectionProviderNotRegisteredError, + ConnectionReauthRequiredError, + ConnectionRefreshNotSupportedError, + NoHandlerError, + PluginNotLoadedError, + SecretInUseError, + SecretOwnedByConnectionError, + SourceRemovalNotAllowedError, + ToolBlockedError, + ToolInvocationError, + ToolNotFoundError, +} from "./errors"; +import type { FumaDb, FumaTables, StorageFailure } from "./fuma-runtime"; +import type { OAuthService } from "./oauth"; +import type { OAuthEndpointUrlPolicy } from "./oauth-helpers"; +import type { + CreateToolPolicyInput, + PolicyMatch, + RemoveToolPolicyInput, + ToolPolicy, + UpdateToolPolicyInput, +} from "./policies"; +import type { AnyPlugin, PluginExtensions } from "./plugin"; +import type { Scope } from "./scope"; +import type { RemoveSecretInput, SecretRef, SetSecretInput } from "./secrets"; +import type { + RefreshSourceInput, + RemoveSourceInput, + Source, + SourceDetectionResult, + Tool, + ToolListFilter, + ToolSchema, +} from "./types"; +import type { Usage } from "./usages"; + +// --------------------------------------------------------------------------- +// Executor public types. Kept separate from createExecutor so the runtime +// module stays focused on wiring and lifecycle. +// --------------------------------------------------------------------------- + +export type OnElicitation = ElicitationHandler | "accept-all"; + +export interface InvokeOptions { + /** Override the executor-level handler for this single call. */ + readonly onElicitation?: OnElicitation; +} + +// --------------------------------------------------------------------------- +// Executor — public surface. Every list/invoke/schema call is a direct +// core-table query (for dynamic rows) unioned with the in-memory static +// pool. No ToolRegistry, no SourceRegistry, no SecretStore services. +// --------------------------------------------------------------------------- + +export type Executor = { + /** + * Precedence-ordered scope stack this executor was configured with. + * Innermost first. Consumers that need "the display scope" typically + * pick `scopes.at(-1)` (outermost, e.g. the organization) or + * `scopes[0]` (innermost, e.g. the current user-in-org) depending on + * what they're rendering. + */ + readonly scopes: readonly Scope[]; + + readonly tools: { + readonly list: (filter?: ToolListFilter) => Effect.Effect; + /** Fetch a tool's full schema view: JSON schemas with `$defs` + * attached from the core `definition` table, plus TypeScript + * preview strings rendered from them. Returns `null` for unknown + * tool ids. */ + readonly schema: (toolId: string) => Effect.Effect; + /** Every `$defs` entry across every source, grouped by source id. + * Used for bulk schema export and downstream TypeScript rendering. */ + readonly definitions: () => Effect.Effect< + Record>, + StorageFailure + >; + readonly invoke: ( + toolId: string, + args: unknown, + options?: InvokeOptions, + ) => Effect.Effect< + unknown, + | ToolNotFoundError + | ToolBlockedError + | PluginNotLoadedError + | NoHandlerError + | ToolInvocationError + | ElicitationDeclinedError + | StorageFailure + >; + }; + + readonly sources: { + readonly list: () => Effect.Effect; + readonly remove: ( + input: RemoveSourceInput, + ) => Effect.Effect; + readonly refresh: (input: RefreshSourceInput) => Effect.Effect; + /** URL autodetection — fans out to every plugin's `detect` hook + * (if declared), returns every high/medium/low-confidence match. + * UI picks a winner from the list. */ + readonly detect: ( + url: string, + ) => Effect.Effect; + /** All `$defs` registered for a single source, keyed by def name. */ + readonly definitions: ( + sourceId: string, + ) => Effect.Effect, StorageFailure>; + }; + + readonly secrets: { + readonly get: ( + id: string, + ) => Effect.Effect; + readonly getAtScope: ( + id: string, + scope: string, + ) => Effect.Effect; + /** Fast-path existence check — hits the core `secret` routing table + * only, never calls the provider. Use this for UI state ("secret + * missing, prompt to add") to avoid keychain permission prompts + * or 1password IPC roundtrips on a pre-flight check. */ + readonly status: (id: string) => Effect.Effect<"resolved" | "missing", StorageFailure>; + readonly set: (input: SetSecretInput) => Effect.Effect; + /** Delete a bare (non-connection-owned) secret. Connection-owned + * secrets are rejected with `SecretOwnedByConnectionError` — use + * `connections.remove` instead. Refuses with `SecretInUseError` + * if any plugin reports the secret as in use; the caller should + * show the `usages(id)` list and ask the user to detach first. */ + readonly remove: ( + input: RemoveSecretInput, + ) => Effect.Effect; + readonly list: () => Effect.Effect; + /** Management view of visible secret rows. Unlike `list`, this does + * not collapse same-id rows across scopes, so UI that writes exact + * credential targets can show both personal and shared rows. */ + readonly listAll: () => Effect.Effect; + /** All places this secret is referenced — fans out across every + * plugin's `usagesForSecret`. Used by the Secrets-tab "Used by" + * list and by `remove` for its RESTRICT check. */ + readonly usages: (id: string) => Effect.Effect; + readonly providers: () => Effect.Effect; + }; + + readonly connections: { + readonly get: (id: string) => Effect.Effect; + readonly getAtScope: ( + id: string, + scope: string, + ) => Effect.Effect; + readonly list: () => Effect.Effect; + readonly create: ( + input: CreateConnectionInput, + ) => Effect.Effect; + readonly updateTokens: ( + input: UpdateConnectionTokensInput, + ) => Effect.Effect; + readonly setIdentityLabel: ( + id: string, + label: string | null, + ) => Effect.Effect; + readonly accessToken: ( + id: string, + ) => Effect.Effect< + string, + | ConnectionNotFoundError + | ConnectionProviderNotRegisteredError + | ConnectionRefreshNotSupportedError + | ConnectionReauthRequiredError + | ConnectionRefreshError + | StorageFailure + >; + readonly accessTokenAtScope: ( + id: string, + scope: string, + ) => Effect.Effect< + string, + | ConnectionNotFoundError + | ConnectionProviderNotRegisteredError + | ConnectionRefreshNotSupportedError + | ConnectionReauthRequiredError + | ConnectionRefreshError + | StorageFailure + >; + /** Refuses with `ConnectionInUseError` if any plugin reports the + * connection as in use. */ + readonly remove: ( + input: RemoveConnectionInput, + ) => Effect.Effect; + /** All places this connection is referenced — fans out across every + * plugin's `usagesForConnection`. */ + readonly usages: (id: string) => Effect.Effect; + readonly providers: () => Effect.Effect; + }; + + /** Shared credential slot bindings. Plugins decide what slot keys mean; + * core owns scoped storage, resolution status, and usage visibility. */ + readonly credentialBindings: CredentialBindingsFacade; + + /** Shared OAuth service. Hosts use this through the core HTTP OAuth group; + * plugins see the same service as `ctx.oauth`. */ + readonly oauth: OAuthService; + + readonly policies: { + /** All policies visible across the executor's scope stack, sorted + * by (innermost-scope-first, position ascending) — i.e. the order + * in which they're evaluated by first-match-wins. */ + readonly list: () => Effect.Effect; + /** Create a new policy. Defaults to the top of the target scope's + * list (highest precedence) when `position` is omitted. */ + readonly create: (input: CreateToolPolicyInput) => Effect.Effect; + readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; + readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; + /** Resolve the effective policy for a tool id by walking the scope- + * stacked policy list with first-match-wins semantics. Returns + * `undefined` when no rule matches (caller falls back to the + * plugin's `resolveAnnotations` output). */ + readonly resolve: (toolId: string) => Effect.Effect; + }; + + readonly close: () => Effect.Effect; +} & PluginExtensions; + +export interface ExecutorDb { + readonly db: FumaDb; + readonly close?: () => Effect.Effect | Promise | void; +} + +export type ExecutorDbInput = FumaDb | ExecutorDb; + +export type ExecutorDbFactory = (config: { + readonly tables: FumaTables; +}) => ExecutorDbInput | Effect.Effect; + +export interface ExecutorConfig { + /** + * Precedence-ordered scope stack. Innermost first; typical shape is + * `[userInOrgScope, orgScope]`. Reads on scoped tables walk the + * stack (first hit wins for shadow-by-id consumers like secrets and + * blobs); writes require callers to name an explicit target scope. + * Must be non-empty. + */ + readonly scopes: readonly Scope[]; + readonly db?: ExecutorDbInput | ExecutorDbFactory; + readonly plugins?: TPlugins; + /** + * How to respond when a tool requests user input mid-invocation. Pass + * `"accept-all"` for tests / non-interactive hosts, or a handler + * `(ctx) => Effect` for interactive ones. + * Required at construction so per-invoke calls don't have to thread + * an options arg. + */ + readonly onElicitation: OnElicitation; + readonly httpClientLayer?: Layer.Layer; + readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly sourceDetection?: { + readonly maxUrlLength?: number; + readonly maxDetectors?: number; + readonly maxResults?: number; + readonly timeout?: Duration.Input; + readonly hostedOutboundPolicy?: boolean; + }; +} diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index ab978152d..da0cecff8 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,71 +1,19 @@ -import { Duration, Effect, Layer } from "effect"; -import type { HttpClient } from "effect/unstable/http"; +import { Effect } from "effect"; import { withQueryContext } from "fumadb/query"; -import type { OAuthEndpointUrlPolicy } from "./oauth-helpers"; -import { - StorageError, - makeFumaClient, - type FumaDb, - type FumaTables, - type StorageFailure, -} from "./fuma-runtime"; +import { StorageError, makeFumaClient, type StorageFailure } from "./fuma-runtime"; import { makeFumaBlobStore } from "./blob"; -import { - ConnectionRef, - ConnectionRefreshError, - type ConnectionProvider, - type CreateConnectionInput, - type RemoveConnectionInput, - type UpdateConnectionTokensInput, -} from "./connections"; -import { type CredentialBindingsFacade } from "./credential-bindings"; +import type { ConnectionProvider } from "./connections"; import { type ConnectionRow, type SecretRow, type SourceRow } from "./core-schema"; -import { - ElicitationDeclinedError, - ElicitationResponse, - type ElicitationHandler, -} from "./elicitation"; -import { - ConnectionInUseError, - ConnectionNotFoundError, - ConnectionProviderNotRegisteredError, - ConnectionReauthRequiredError, - ConnectionRefreshNotSupportedError, - NoHandlerError, - PluginNotLoadedError, - SecretInUseError, - SecretOwnedByConnectionError, - SourceRemovalNotAllowedError, - ToolBlockedError, - ToolInvocationError, - ToolNotFoundError, -} from "./errors"; +import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; import { makeOAuth2Service } from "./oauth-service"; -import type { OAuthService } from "./oauth"; -import { - type CreateToolPolicyInput, - type PolicyMatch, - type RemoveToolPolicyInput, - type ToolPolicy, - type UpdateToolPolicyInput, -} from "./policies"; -import type { AnyPlugin, PluginExtensions } from "./plugin"; -import type { Scope } from "./scope"; -import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; -import { Usage } from "./usages"; -import { - ToolSchema, - type RefreshSourceInput, - type RemoveSourceInput, - type Source, - type SourceDetectionResult, - type Tool, - type ToolListFilter, -} from "./types"; +import type { AnyPlugin } from "./plugin"; +import type { SecretProvider } from "./secrets"; +import type { Usage } from "./usages"; import type { ExecutorScopePolicyContext } from "./scope-policy"; import { makeCredentialBindings } from "./executor-credential-bindings"; import { makeConnectionsFacade } from "./executor-connections"; +import type { Executor, ExecutorConfig, ExecutorDbInput, OnElicitation } from "./executor-types"; import { registerExecutorPlugins, type ExecutorPluginRuntime, @@ -104,12 +52,15 @@ import { // handler is the fallback, never null. // --------------------------------------------------------------------------- -export type OnElicitation = ElicitationHandler | "accept-all"; - -export interface InvokeOptions { - /** Override the executor-level handler for this single call. */ - readonly onElicitation?: OnElicitation; -} +export type { + Executor, + ExecutorConfig, + ExecutorDb, + ExecutorDbFactory, + ExecutorDbInput, + InvokeOptions, + OnElicitation, +} from "./executor-types"; const acceptAllHandler: ElicitationHandler = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); @@ -117,223 +68,6 @@ const acceptAllHandler: ElicitationHandler = () => const resolveElicitationHandler = (onElicitation: OnElicitation): ElicitationHandler => onElicitation === "accept-all" ? acceptAllHandler : onElicitation; -// --------------------------------------------------------------------------- -// Executor — public surface. Every list/invoke/schema call is a direct -// core-table query (for dynamic rows) unioned with the in-memory static -// pool. No ToolRegistry, no SourceRegistry, no SecretStore services. -// --------------------------------------------------------------------------- - -export type Executor = { - /** - * Precedence-ordered scope stack this executor was configured with. - * Innermost first. Consumers that need "the display scope" typically - * pick `scopes.at(-1)` (outermost, e.g. the organization) or - * `scopes[0]` (innermost, e.g. the current user-in-org) depending on - * what they're rendering. - */ - readonly scopes: readonly Scope[]; - - readonly tools: { - readonly list: (filter?: ToolListFilter) => Effect.Effect; - /** Fetch a tool's full schema view: JSON schemas with `$defs` - * attached from the core `definition` table, plus TypeScript - * preview strings rendered from them. Returns `null` for unknown - * tool ids. */ - readonly schema: (toolId: string) => Effect.Effect; - /** Every `$defs` entry across every source, grouped by source id. - * Used for bulk schema export and downstream TypeScript rendering. */ - readonly definitions: () => Effect.Effect< - Record>, - StorageFailure - >; - readonly invoke: ( - toolId: string, - args: unknown, - options?: InvokeOptions, - ) => Effect.Effect< - unknown, - | ToolNotFoundError - | ToolBlockedError - | PluginNotLoadedError - | NoHandlerError - | ToolInvocationError - | ElicitationDeclinedError - | StorageFailure - >; - }; - - readonly sources: { - readonly list: () => Effect.Effect; - readonly remove: ( - input: RemoveSourceInput, - ) => Effect.Effect; - readonly refresh: (input: RefreshSourceInput) => Effect.Effect; - /** URL autodetection — fans out to every plugin's `detect` hook - * (if declared), returns every high/medium/low-confidence match. - * UI picks a winner from the list. */ - readonly detect: ( - url: string, - ) => Effect.Effect; - /** All `$defs` registered for a single source, keyed by def name. */ - readonly definitions: ( - sourceId: string, - ) => Effect.Effect, StorageFailure>; - }; - - readonly secrets: { - readonly get: ( - id: string, - ) => Effect.Effect; - readonly getAtScope: ( - id: string, - scope: string, - ) => Effect.Effect; - /** Fast-path existence check — hits the core `secret` routing table - * only, never calls the provider. Use this for UI state ("secret - * missing, prompt to add") to avoid keychain permission prompts - * or 1password IPC roundtrips on a pre-flight check. */ - readonly status: (id: string) => Effect.Effect<"resolved" | "missing", StorageFailure>; - readonly set: (input: SetSecretInput) => Effect.Effect; - /** Delete a bare (non-connection-owned) secret. Connection-owned - * secrets are rejected with `SecretOwnedByConnectionError` — use - * `connections.remove` instead. Refuses with `SecretInUseError` - * if any plugin reports the secret as in use; the caller should - * show the `usages(id)` list and ask the user to detach first. */ - readonly remove: ( - input: RemoveSecretInput, - ) => Effect.Effect; - readonly list: () => Effect.Effect; - /** Management view of visible secret rows. Unlike `list`, this does - * not collapse same-id rows across scopes, so UI that writes exact - * credential targets can show both personal and shared rows. */ - readonly listAll: () => Effect.Effect; - /** All places this secret is referenced — fans out across every - * plugin's `usagesForSecret`. Used by the Secrets-tab "Used by" - * list and by `remove` for its RESTRICT check. */ - readonly usages: (id: string) => Effect.Effect; - readonly providers: () => Effect.Effect; - }; - - readonly connections: { - readonly get: (id: string) => Effect.Effect; - readonly getAtScope: ( - id: string, - scope: string, - ) => Effect.Effect; - readonly list: () => Effect.Effect; - readonly create: ( - input: CreateConnectionInput, - ) => Effect.Effect; - readonly updateTokens: ( - input: UpdateConnectionTokensInput, - ) => Effect.Effect; - readonly setIdentityLabel: ( - id: string, - label: string | null, - ) => Effect.Effect; - readonly accessToken: ( - id: string, - ) => Effect.Effect< - string, - | ConnectionNotFoundError - | ConnectionProviderNotRegisteredError - | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError - | ConnectionRefreshError - | StorageFailure - >; - readonly accessTokenAtScope: ( - id: string, - scope: string, - ) => Effect.Effect< - string, - | ConnectionNotFoundError - | ConnectionProviderNotRegisteredError - | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError - | ConnectionRefreshError - | StorageFailure - >; - /** Refuses with `ConnectionInUseError` if any plugin reports the - * connection as in use. */ - readonly remove: ( - input: RemoveConnectionInput, - ) => Effect.Effect; - /** All places this connection is referenced — fans out across every - * plugin's `usagesForConnection`. */ - readonly usages: (id: string) => Effect.Effect; - readonly providers: () => Effect.Effect; - }; - - /** Shared credential slot bindings. Plugins decide what slot keys mean; - * core owns scoped storage, resolution status, and usage visibility. */ - readonly credentialBindings: CredentialBindingsFacade; - - /** Shared OAuth service. Hosts use this through the core HTTP OAuth group; - * plugins see the same service as `ctx.oauth`. */ - readonly oauth: OAuthService; - - readonly policies: { - /** All policies visible across the executor's scope stack, sorted - * by (innermost-scope-first, position ascending) — i.e. the order - * in which they're evaluated by first-match-wins. */ - readonly list: () => Effect.Effect; - /** Create a new policy. Defaults to the top of the target scope's - * list (highest precedence) when `position` is omitted. */ - readonly create: (input: CreateToolPolicyInput) => Effect.Effect; - readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; - readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; - /** Resolve the effective policy for a tool id by walking the scope- - * stacked policy list with first-match-wins semantics. Returns - * `undefined` when no rule matches (caller falls back to the - * plugin's `resolveAnnotations` output). */ - readonly resolve: (toolId: string) => Effect.Effect; - }; - - readonly close: () => Effect.Effect; -} & PluginExtensions; - -export interface ExecutorDb { - readonly db: FumaDb; - readonly close?: () => Effect.Effect | Promise | void; -} - -export type ExecutorDbInput = FumaDb | ExecutorDb; - -export type ExecutorDbFactory = (config: { - readonly tables: FumaTables; -}) => ExecutorDbInput | Effect.Effect; - -export interface ExecutorConfig { - /** - * Precedence-ordered scope stack. Innermost first; typical shape is - * `[userInOrgScope, orgScope]`. Reads on scoped tables walk the - * stack (first hit wins for shadow-by-id consumers like secrets and - * blobs); writes require callers to name an explicit target scope. - * Must be non-empty. - */ - readonly scopes: readonly Scope[]; - readonly db?: ExecutorDbInput | ExecutorDbFactory; - readonly plugins?: TPlugins; - /** - * How to respond when a tool requests user input mid-invocation. Pass - * `"accept-all"` for tests / non-interactive hosts, or a handler - * `(ctx) => Effect` for interactive ones. - * Required at construction so per-invoke calls don't have to thread - * an options arg. - */ - readonly onElicitation: OnElicitation; - readonly httpClientLayer?: Layer.Layer; - readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy; - readonly sourceDetection?: { - readonly maxUrlLength?: number; - readonly maxDetectors?: number; - readonly maxResults?: number; - readonly timeout?: Duration.Input; - readonly hostedOutboundPolicy?: boolean; - }; -} - export { collectTables }; // ---------------------------------------------------------------------------