import { Clock, Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect";import { FetchHttpClient, type HttpClient } from "effect/unstable/http";import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore";import { fumadb } from "@executor-js/fumadb";import { memoryAdapter } from "@executor-js/fumadb/adapters/memory";import { withQueryContext, type Condition, type ConditionBuilder } from "@executor-js/fumadb/query";import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema";import type { AnyColumn } from "@executor-js/fumadb/schema";import { generateKeyBetween } from "fractional-indexing"; import { StorageError, isStorageFailure, makeFumaClient, type FumaDb, type FumaRow, type FumaTables, type StorageFailure,} from "./fuma-runtime";import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob";import { coreToolsPlugin } from "./core-tools";import type { Connection, ConnectionInputOrigin, ConnectionRef, CreateConnectionInput, ConnectionValueInput, UpdateConnectionInput,} from "./connection";import { coreSchema, isToolPolicyAction, TOOL_INVOCATION_COLUMNS, type ConnectionRow, type CoreSchema, type IntegrationRow, type OAuthClientRow, type ToolInvocationRow, type ToolRow, type ToolPolicyRow,} from "./core-schema";import { ElicitationDeclinedError, ElicitationResponse, FormElicitation, type ElicitationHandler, type ElicitationRequest, type OnElicitation, type InvokeOptions,} from "./elicitation"; export type { OnElicitation, InvokeOptions } from "./elicitation";import { ConnectionNotFoundError, CredentialProviderNotRegisteredError, CredentialResolutionError, IntegrationNotFoundError, InvalidConnectionInputError, IntegrationRemovalNotAllowedError, NoHandlerError, PluginNotLoadedError, ToolBlockedError, ToolInvocationError, ToolNotFoundError, type ExecuteError,} from "./errors";import { AuthTemplateSlug, ConnectionAddress, ConnectionName, IntegrationSlug, NO_AUTH_TEMPLATE, OAuthClientSlug, Owner, PolicyId, ProviderItemId, ProviderKey, Subject, Tenant, ToolAddress, ToolName,} from "./ids";import type { AuthMethodDescriptor, Integration, IntegrationConfig, RegisterIntegrationInput,} from "./integration";import { makeOAuthService, type MintOAuthConnectionInput, type OAuthScopePolicy,} from "./oauth-service";import type { OAuthService } from "./oauth-client";import { comparePolicyRow, isValidPattern, matchPattern, resolveEffectivePolicy, rowToToolPolicy, type CreateToolPolicyInput, type EffectivePolicy, type RemoveToolPolicyInput, type ToolPolicy, type UpdateToolPolicyInput,} from "./policies";import type { CredentialProvider, ProviderEntry } from "./provider";import type { AnyPlugin, Elicit, IntegrationConfigureSchema, IntegrationPresetCatalogEntry, IntegrationRecord, OwnerBinding, PluginCtx, PluginExtensions, ResolveToolsResult, StaticSourceDecl, StaticToolDecl, StorageDeps, ToolPolicyProvider, ToolPolicyProviderRule, ToolInvocationCredential,} from "./plugin";import { pluginStorageId, type PluginStorageCollectionData, type PluginStorageCollectionDefinition, type PluginStorageCollectionQueryInput, type PluginStorageEntry, type PluginStorageFacade, type PluginStorageRuntimeCollectionDefinition, type PluginStorageRuntimeIndexSpec,} from "./plugin-storage";import { assertExecutorOwnerPolicyTable, ORG_SUBJECT, type ExecutorOwnerPolicyContext,} from "./owner-policy";import { ToolSchemaView, type IntegrationDetectionResult } from "./types";import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool";import { buildToolTypeScriptPreview } from "./schema-types";import { collectReferencedDefinitions } from "./schema-refs";import { refreshAccessToken, exchangeClientCredentials, shouldRefreshToken, type OAuthEndpointUrlPolicy,} from "./oauth-helpers";import { connectionIdentifier } from "./connection-name-identifier";import { annotateToolResultOutcome } from "./tool-result"; const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90;const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90;const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; // ---------------------------------------------------------------------------// Elicitation handler — resolved once at `createExecutor({ onElicitation })`// and overridable per `execute`. A tool that requests user input mid-execution// suspends the fiber and the handler decides how to respond. The "accept-all"// sentinel auto-accepts (tests / non-interactive hosts).// --------------------------------------------------------------------------- const acceptAllHandler: ElicitationHandler = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); const resolveElicitationHandler = (onElicitation: OnElicitation): ElicitationHandler => onElicitation === "accept-all" ? acceptAllHandler : onElicitation; // ---------------------------------------------------------------------------// Address scheme — `tools.<integration>.<owner>.<connection>.<tool>`.// --------------------------------------------------------------------------- const ADDRESS_PREFIX = "tools"; export interface ParsedToolAddress { readonly integration: IntegrationSlug; readonly owner: Owner; readonly connection: ConnectionName; readonly tool: ToolName;} const isOwner = (value: string): value is Owner => value === "org" || value === "user"; /** Parse a callable address; null when it's not a well-formed * `tools.<integration>.<owner>.<connection>.<tool>`. * * The four leading segments (prefix, integration, owner, connection) are * slug-like and never contain a `.`; the `<tool>` segment is the *entire* * remainder after the 4th dot, so it may itself contain dots. That lets a tool * whose name is a structured `group.leaf` path (e.g. an OpenAPI * `aliases.deleteAlias`) address naturally as * `tools.<integration>.<owner>.<connection>.aliases.deleteAlias` — the same * dotted nesting the sandbox `tools` proxy produces from property access. */export const parseToolAddress = (address: string): ParsedToolAddress | null => { // Walk to the 4th dot; everything past it is the tool (dots and all). let cut = -1; for (let i = 0; i < 4; i++) { cut = address.indexOf(".", cut + 1); if (cut === -1) return null; } const [prefix, integration, owner, connection] = address.slice(0, cut).split(".") as [ string, string, string, string, ]; const tool = address.slice(cut + 1); if (prefix !== ADDRESS_PREFIX) return null; if (!isOwner(owner)) return null; if (integration.length === 0 || connection.length === 0 || tool.length === 0) { return null; } return { integration: IntegrationSlug.make(integration), owner, connection: ConnectionName.make(connection), tool: ToolName.make(tool), };}; export const connectionAddress = ( owner: Owner, integration: IntegrationSlug, connection: ConnectionName,): ConnectionAddress => ConnectionAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}`); export const toolAddress = ( owner: Owner, integration: IntegrationSlug, connection: ConnectionName, tool: ToolName,): ToolAddress => ToolAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}.${tool}`); // ---------------------------------------------------------------------------// Owner key helpers — every owned-row write stamps `tenant`, `owner`,// `subject` (org → subject="").// --------------------------------------------------------------------------- interface OwnedKeys { readonly tenant: string; readonly owner: Owner; readonly subject: string;} // ---------------------------------------------------------------------------// Executor — public surface. Every list/execute/schema call is a direct// core-table query unioned with the in-memory static pool.// --------------------------------------------------------------------------- export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { readonly integrations: { readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>; readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>; readonly update: ( slug: IntegrationSlug, patch: { readonly name?: string; readonly description?: string }, ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>; readonly remove: ( slug: IntegrationSlug, ) => Effect.Effect<void, IntegrationRemovalNotAllowedError | StorageFailure>; readonly detect: ( url: string, ) => Effect.Effect<readonly IntegrationDetectionResult[], StorageFailure>; }; readonly connections: { readonly create: ( input: CreateConnectionInput, ) => Effect.Effect< Connection, | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError | StorageFailure >; readonly list: (filter?: { readonly integration?: IntegrationSlug; readonly owner?: Owner; }) => Effect.Effect<readonly Connection[], StorageFailure>; readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>; /** Edit user-curated metadata (description, identityLabel). Credentials and * OAuth lifecycle fields are not editable here. */ readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, ) => Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure>; readonly remove: ( ref: ConnectionRef, ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< readonly Tool[], ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure >; }; /** Shared OAuth service. Hosts use this through the core HTTP OAuth group; * plugins see the same service as `ctx.oauth`. */ readonly oauth: OAuthService; readonly tools: { readonly list: (filter?: ToolListFilter) => Effect.Effect<readonly Tool[], StorageFailure>; readonly schema: (address: ToolAddress) => Effect.Effect<ToolSchemaView | null, StorageFailure>; }; readonly providers: { readonly list: () => Effect.Effect<readonly ProviderKey[]>; readonly items: (key: ProviderKey) => Effect.Effect<readonly ProviderEntry[], StorageFailure>; }; readonly policies: { readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>; readonly create: (input: CreateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>; readonly update: (input: UpdateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>; readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect<void, StorageFailure>; readonly resolve: (address: ToolAddress) => Effect.Effect<EffectivePolicy, StorageFailure>; }; readonly execute: ( address: ToolAddress, args: unknown, options?: InvokeOptions, ) => Effect.Effect<unknown, ExecuteError>; readonly close: () => Effect.Effect<void, StorageFailure>; /** Ephemeral key-value cache for derived data. Host-provided when durable * storage exists (e.g. Cloudflare KV via `ExecutorConfig.cache`); otherwise * a bounded in-memory TTL store scoped to this executor. Consumers must * treat entries as best-effort and re-derivable — a cold cache is always * correct — and must NOT key anything tenant-sensitive without prefixing, * since a host-provided namespace may outlive and span executors. */ readonly cache: KeyValueStore.KeyValueStore;} & PluginExtensions<TPlugins>; export interface ExecutorDb { readonly db: FumaDb<any>; readonly close?: () => Effect.Effect<void, StorageFailure> | Promise<void> | void;} export type ExecutorDbInput = FumaDb<any> | ExecutorDb; export type ExecutorDbFactory = (config: { readonly tables: FumaTables;}) => ExecutorDbInput | Effect.Effect<ExecutorDbInput, StorageFailure>; export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly []> { /** The org / workspace this executor is bound to. `owner: "org"` rows file * here. */ readonly tenant: Tenant; /** The acting member, or omit for a pure-org executor (no `owner:"user"`). */ readonly subject?: Subject; readonly db?: ExecutorDbInput | ExecutorDbFactory; /** * Backend for the plugin blob seam (`StorageDeps.blobs`). Defaults to the * FumaDB `blob` table over `db`. Hosts with an object store hand one in * (e.g. the R2 store in `@executor-js/cloudflare/blob-store`) so multi-MB * values stay out of the relational tier. */ readonly blobs?: BlobStore; /** * Backend for `executor.cache`. Hosts with a durable KV tier hand one in * (e.g. `makeCloudflareKeyValueStore` from `@executor-js/cloudflare/key-value-store`); * without one the executor falls back to a bounded in-memory TTL store, * which only helps within a single long-lived process. */ readonly cache?: KeyValueStore.KeyValueStore; readonly plugins?: TPlugins; /** Config-level credential providers, merged with every * `plugin.credentialProviders`. Config providers register first, so the * default (first writable) store is selected from them when present. */ readonly providers?: readonly CredentialProvider[]; /** * How to respond when a tool requests user input mid-invocation. Pass * `"accept-all"` for tests / non-interactive hosts, or a handler. */ readonly onElicitation: OnElicitation; readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; /** * Fetch API implementation for dependencies that cannot consume `httpClientLayer`. * Prefer `httpClientLayer` for normal SDK and plugin HTTP. */ readonly fetch?: typeof globalThis.fetch; /** * The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and * sends to providers. There is NO localhost default: omit it (or pass * undefined) only for executors that never run interactive OAuth — the * redirect-requiring flows then fail loudly instead of guessing a callback. * Hosts serving OAuth derive this from the request origin / web base URL. */ readonly redirectUri?: string; /** Optional URL selected organization slug to carry inside OAuth `state`. */ readonly oauthCallbackStateOrgSlug?: string; readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * Enable the built-in `core-tools` plugin which contributes agent-facing * static tools over the v2 surface (integrations / connections / policies). */ readonly coreTools?: { readonly webBaseUrl?: string; readonly orgSlug?: string; readonly includeProviders?: boolean; };} // ---------------------------------------------------------------------------// collectTables — return the executor-owned Fuma table set. Plugins persist// through host-owned facades (`pluginStorage`, `blobs`) instead of contributing// table definitions, so the schema is fixed and plugin-independent.// --------------------------------------------------------------------------- export const collectTables = (): FumaTables => { validateExecutorOwnerPolicyTables(coreSchema); return { ...coreSchema };}; const validateExecutorOwnerPolicyTables = (tables: FumaTables): void => { for (const [tableKey, tableDef] of Object.entries(tables)) { assertExecutorOwnerPolicyTable(tableDef, tableKey); }}; const validateExecutorDbTables = (required: FumaTables, actual: FumaTables): void => { const missing = Object.keys(required) .filter((tableName) => !actual[tableName]) .sort(); if (missing.length === 0) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: synchronous startup validation before Executor services are built throw new StorageError({ message: `Executor database is missing required table definitions: ${missing.join(", ")}`, cause: { missing, available: Object.keys(actual).sort(), }, });}; const storageFailureFromUnknown = (message: string, cause: unknown): StorageFailure => isStorageFailure(cause) ? cause : new StorageError({ message, cause }); // ---------------------------------------------------------------------------// Default `executor.cache` backend — a bounded in-memory string store with TTL// expiry and LRU eviction (Map iteration order is insertion order; touched// keys are re-inserted). Deliberately modest: it exists so cache consumers can// be written against one interface everywhere, while hosts with real KV// (Cloudflare) swap in a durable backend via `ExecutorConfig.cache`.// --------------------------------------------------------------------------- const MEMORY_CACHE_CAPACITY = 2_048;const MEMORY_CACHE_TTL_MS = 10 * 60 * 1000; const makeMemoryCacheStore = (): KeyValueStore.KeyValueStore => { const rows = new Map<string, { readonly value: string; readonly expiresAt: number }>(); const evictExpired = (now: number): void => { for (const [key, entry] of rows) { if (entry.expiresAt <= now) rows.delete(key); } }; const evictOverCapacity = (): void => { while (rows.size > MEMORY_CACHE_CAPACITY) { const oldest = rows.keys().next().value; if (oldest === undefined) break; rows.delete(oldest); } }; return KeyValueStore.makeStringOnly({ get: (key) => Effect.map(Clock.currentTimeMillis, (now) => { const entry = rows.get(key); if (entry === undefined) return undefined; if (entry.expiresAt <= now) { rows.delete(key); return undefined; } // LRU touch: move to the back of the insertion order. rows.delete(key); rows.set(key, entry); return entry.value; }), set: (key, value) => Effect.map(Clock.currentTimeMillis, (now) => { evictExpired(now); rows.delete(key); rows.set(key, { value, expiresAt: now + MEMORY_CACHE_TTL_MS }); evictOverCapacity(); }), remove: (key) => Effect.sync(() => { rows.delete(key); }), clear: Effect.sync(() => { rows.clear(); }), size: Effect.map(Clock.currentTimeMillis, (now) => { evictExpired(now);Lines 1–500 of 3568. Load more
import { Clock, Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; import { fumadb } from "@executor-js/fumadb"; import { memoryAdapter } from "@executor-js/fumadb/adapters/memory"; import { withQueryContext, type Condition, type ConditionBuilder } from "@executor-js/fumadb/query"; import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema"; import type { AnyColumn } from "@executor-js/fumadb/schema"; import { generateKeyBetween } from "fractional-indexing"; import { StorageError, isStorageFailure, makeFumaClient, type FumaDb, type FumaRow, type FumaTables, type StorageFailure, } from "./fuma-runtime"; import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob"; import { coreToolsPlugin } from "./core-tools"; import type { Connection, ConnectionInputOrigin, ConnectionRef, CreateConnectionInput, ConnectionValueInput, UpdateConnectionInput, } from "./connection"; import { coreSchema, isToolPolicyAction, TOOL_INVOCATION_COLUMNS, type ConnectionRow, type CoreSchema, type IntegrationRow, type OAuthClientRow, type ToolInvocationRow, type ToolRow, type ToolPolicyRow, } from "./core-schema"; import { ElicitationDeclinedError, ElicitationResponse, FormElicitation, type ElicitationHandler, type ElicitationRequest, type OnElicitation, type InvokeOptions, } from "./elicitation"; export type { OnElicitation, InvokeOptions } from "./elicitation"; import { ConnectionNotFoundError, CredentialProviderNotRegisteredError, CredentialResolutionError, IntegrationNotFoundError, InvalidConnectionInputError, IntegrationRemovalNotAllowedError, NoHandlerError, PluginNotLoadedError, ToolBlockedError, ToolInvocationError, ToolNotFoundError, type ExecuteError, } from "./errors"; import { AuthTemplateSlug, ConnectionAddress, ConnectionName, IntegrationSlug, NO_AUTH_TEMPLATE, OAuthClientSlug, Owner, PolicyId, ProviderItemId, ProviderKey, Subject, Tenant, ToolAddress, ToolName, } from "./ids"; import type { AuthMethodDescriptor, Integration, IntegrationConfig, RegisterIntegrationInput, } from "./integration"; import { makeOAuthService, type MintOAuthConnectionInput, type OAuthScopePolicy, } from "./oauth-service"; import type { OAuthService } from "./oauth-client"; import { comparePolicyRow, isValidPattern, matchPattern, resolveEffectivePolicy, rowToToolPolicy, type CreateToolPolicyInput, type EffectivePolicy, type RemoveToolPolicyInput, type ToolPolicy, type UpdateToolPolicyInput, } from "./policies"; import type { CredentialProvider, ProviderEntry } from "./provider"; import type { AnyPlugin, Elicit, IntegrationConfigureSchema, IntegrationPresetCatalogEntry, IntegrationRecord, OwnerBinding, PluginCtx, PluginExtensions, ResolveToolsResult, StaticSourceDecl, StaticToolDecl, StorageDeps, ToolPolicyProvider, ToolPolicyProviderRule, ToolInvocationCredential, } from "./plugin"; import { pluginStorageId, type PluginStorageCollectionData, type PluginStorageCollectionDefinition, type PluginStorageCollectionQueryInput, type PluginStorageEntry, type PluginStorageFacade, type PluginStorageRuntimeCollectionDefinition, type PluginStorageRuntimeIndexSpec, } from "./plugin-storage"; import { assertExecutorOwnerPolicyTable, ORG_SUBJECT, type ExecutorOwnerPolicyContext, } from "./owner-policy"; import { ToolSchemaView, type IntegrationDetectionResult } from "./types"; import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool"; import { buildToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; import { refreshAccessToken, exchangeClientCredentials, shouldRefreshToken, type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; import { connectionIdentifier } from "./connection-name-identifier"; import { annotateToolResultOutcome } from "./tool-result"; const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90; const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; // --------------------------------------------------------------------------- // Elicitation handler — resolved once at `createExecutor({ onElicitation })` // and overridable per `execute`. A tool that requests user input mid-execution // suspends the fiber and the handler decides how to respond. The "accept-all" // sentinel auto-accepts (tests / non-interactive hosts). // --------------------------------------------------------------------------- const acceptAllHandler: ElicitationHandler = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); const resolveElicitationHandler = (onElicitation: OnElicitation): ElicitationHandler => onElicitation === "accept-all" ? acceptAllHandler : onElicitation; // --------------------------------------------------------------------------- // Address scheme — `tools.<integration>.<owner>.<connection>.<tool>`. // --------------------------------------------------------------------------- const ADDRESS_PREFIX = "tools"; export interface ParsedToolAddress { readonly integration: IntegrationSlug; readonly owner: Owner; readonly connection: ConnectionName; readonly tool: ToolName; } const isOwner = (value: string): value is Owner => value === "org" || value === "user"; /** Parse a callable address; null when it's not a well-formed * `tools.<integration>.<owner>.<connection>.<tool>`. * * The four leading segments (prefix, integration, owner, connection) are * slug-like and never contain a `.`; the `<tool>` segment is the *entire* * remainder after the 4th dot, so it may itself contain dots. That lets a tool * whose name is a structured `group.leaf` path (e.g. an OpenAPI * `aliases.deleteAlias`) address naturally as * `tools.<integration>.<owner>.<connection>.aliases.deleteAlias` — the same * dotted nesting the sandbox `tools` proxy produces from property access. */ export const parseToolAddress = (address: string): ParsedToolAddress | null => { // Walk to the 4th dot; everything past it is the tool (dots and all). let cut = -1; for (let i = 0; i < 4; i++) { cut = address.indexOf(".", cut + 1); if (cut === -1) return null; } const [prefix, integration, owner, connection] = address.slice(0, cut).split(".") as [ string, string, string, string, ]; const tool = address.slice(cut + 1); if (prefix !== ADDRESS_PREFIX) return null; if (!isOwner(owner)) return null; if (integration.length === 0 || connection.length === 0 || tool.length === 0) { return null; } return { integration: IntegrationSlug.make(integration), owner, connection: ConnectionName.make(connection), tool: ToolName.make(tool), }; }; export const connectionAddress = ( owner: Owner, integration: IntegrationSlug, connection: ConnectionName, ): ConnectionAddress => ConnectionAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}`); export const toolAddress = ( owner: Owner, integration: IntegrationSlug, connection: ConnectionName, tool: ToolName, ): ToolAddress => ToolAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}.${tool}`); // --------------------------------------------------------------------------- // Owner key helpers — every owned-row write stamps `tenant`, `owner`, // `subject` (org → subject=""). // --------------------------------------------------------------------------- interface OwnedKeys { readonly tenant: string; readonly owner: Owner; readonly subject: string; } // --------------------------------------------------------------------------- // Executor — public surface. Every list/execute/schema call is a direct // core-table query unioned with the in-memory static pool. // --------------------------------------------------------------------------- export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { readonly integrations: { readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>; readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>; readonly update: ( slug: IntegrationSlug, patch: { readonly name?: string; readonly description?: string }, ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>; readonly remove: ( slug: IntegrationSlug, ) => Effect.Effect<void, IntegrationRemovalNotAllowedError | StorageFailure>; readonly detect: ( url: string, ) => Effect.Effect<readonly IntegrationDetectionResult[], StorageFailure>; }; readonly connections: { readonly create: ( input: CreateConnectionInput, ) => Effect.Effect< Connection, | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError | StorageFailure >; readonly list: (filter?: { readonly integration?: IntegrationSlug; readonly owner?: Owner; }) => Effect.Effect<readonly Connection[], StorageFailure>; readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>; /** Edit user-curated metadata (description, identityLabel). Credentials and * OAuth lifecycle fields are not editable here. */ readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, ) => Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure>; readonly remove: ( ref: ConnectionRef, ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< readonly Tool[], ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure >; }; /** Shared OAuth service. Hosts use this through the core HTTP OAuth group; * plugins see the same service as `ctx.oauth`. */ readonly oauth: OAuthService; readonly tools: { readonly list: (filter?: ToolListFilter) => Effect.Effect<readonly Tool[], StorageFailure>; readonly schema: (address: ToolAddress) => Effect.Effect<ToolSchemaView | null, StorageFailure>; }; readonly providers: { readonly list: () => Effect.Effect<readonly ProviderKey[]>; readonly items: (key: ProviderKey) => Effect.Effect<readonly ProviderEntry[], StorageFailure>; }; readonly policies: { readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>; readonly create: (input: CreateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>; readonly update: (input: UpdateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>; readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect<void, StorageFailure>; readonly resolve: (address: ToolAddress) => Effect.Effect<EffectivePolicy, StorageFailure>; }; readonly execute: ( address: ToolAddress, args: unknown, options?: InvokeOptions, ) => Effect.Effect<unknown, ExecuteError>; readonly close: () => Effect.Effect<void, StorageFailure>; /** Ephemeral key-value cache for derived data. Host-provided when durable * storage exists (e.g. Cloudflare KV via `ExecutorConfig.cache`); otherwise * a bounded in-memory TTL store scoped to this executor. Consumers must * treat entries as best-effort and re-derivable — a cold cache is always * correct — and must NOT key anything tenant-sensitive without prefixing, * since a host-provided namespace may outlive and span executors. */ readonly cache: KeyValueStore.KeyValueStore; } & PluginExtensions<TPlugins>; export interface ExecutorDb { readonly db: FumaDb<any>; readonly close?: () => Effect.Effect<void, StorageFailure> | Promise<void> | void; } export type ExecutorDbInput = FumaDb<any> | ExecutorDb; export type ExecutorDbFactory = (config: { readonly tables: FumaTables; }) => ExecutorDbInput | Effect.Effect<ExecutorDbInput, StorageFailure>; export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly []> { /** The org / workspace this executor is bound to. `owner: "org"` rows file * here. */ readonly tenant: Tenant; /** The acting member, or omit for a pure-org executor (no `owner:"user"`). */ readonly subject?: Subject; readonly db?: ExecutorDbInput | ExecutorDbFactory; /** * Backend for the plugin blob seam (`StorageDeps.blobs`). Defaults to the * FumaDB `blob` table over `db`. Hosts with an object store hand one in * (e.g. the R2 store in `@executor-js/cloudflare/blob-store`) so multi-MB * values stay out of the relational tier. */ readonly blobs?: BlobStore; /** * Backend for `executor.cache`. Hosts with a durable KV tier hand one in * (e.g. `makeCloudflareKeyValueStore` from `@executor-js/cloudflare/key-value-store`); * without one the executor falls back to a bounded in-memory TTL store, * which only helps within a single long-lived process. */ readonly cache?: KeyValueStore.KeyValueStore; readonly plugins?: TPlugins; /** Config-level credential providers, merged with every * `plugin.credentialProviders`. Config providers register first, so the * default (first writable) store is selected from them when present. */ readonly providers?: readonly CredentialProvider[]; /** * How to respond when a tool requests user input mid-invocation. Pass * `"accept-all"` for tests / non-interactive hosts, or a handler. */ readonly onElicitation: OnElicitation; readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; /** * Fetch API implementation for dependencies that cannot consume `httpClientLayer`. * Prefer `httpClientLayer` for normal SDK and plugin HTTP. */ readonly fetch?: typeof globalThis.fetch; /** * The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and * sends to providers. There is NO localhost default: omit it (or pass * undefined) only for executors that never run interactive OAuth — the * redirect-requiring flows then fail loudly instead of guessing a callback. * Hosts serving OAuth derive this from the request origin / web base URL. */ readonly redirectUri?: string; /** Optional URL selected organization slug to carry inside OAuth `state`. */ readonly oauthCallbackStateOrgSlug?: string; readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * Enable the built-in `core-tools` plugin which contributes agent-facing * static tools over the v2 surface (integrations / connections / policies). */ readonly coreTools?: { readonly webBaseUrl?: string; readonly orgSlug?: string; readonly includeProviders?: boolean; }; } // --------------------------------------------------------------------------- // collectTables — return the executor-owned Fuma table set. Plugins persist // through host-owned facades (`pluginStorage`, `blobs`) instead of contributing // table definitions, so the schema is fixed and plugin-independent. // --------------------------------------------------------------------------- export const collectTables = (): FumaTables => { validateExecutorOwnerPolicyTables(coreSchema); return { ...coreSchema }; }; const validateExecutorOwnerPolicyTables = (tables: FumaTables): void => { for (const [tableKey, tableDef] of Object.entries(tables)) { assertExecutorOwnerPolicyTable(tableDef, tableKey); } }; const validateExecutorDbTables = (required: FumaTables, actual: FumaTables): void => { const missing = Object.keys(required) .filter((tableName) => !actual[tableName]) .sort(); if (missing.length === 0) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: synchronous startup validation before Executor services are built throw new StorageError({ message: `Executor database is missing required table definitions: ${missing.join(", ")}`, cause: { missing, available: Object.keys(actual).sort(), }, }); }; const storageFailureFromUnknown = (message: string, cause: unknown): StorageFailure => isStorageFailure(cause) ? cause : new StorageError({ message, cause }); // --------------------------------------------------------------------------- // Default `executor.cache` backend — a bounded in-memory string store with TTL // expiry and LRU eviction (Map iteration order is insertion order; touched // keys are re-inserted). Deliberately modest: it exists so cache consumers can // be written against one interface everywhere, while hosts with real KV // (Cloudflare) swap in a durable backend via `ExecutorConfig.cache`. // --------------------------------------------------------------------------- const MEMORY_CACHE_CAPACITY = 2_048; const MEMORY_CACHE_TTL_MS = 10 * 60 * 1000; const makeMemoryCacheStore = (): KeyValueStore.KeyValueStore => { const rows = new Map<string, { readonly value: string; readonly expiresAt: number }>(); const evictExpired = (now: number): void => { for (const [key, entry] of rows) { if (entry.expiresAt <= now) rows.delete(key); } }; const evictOverCapacity = (): void => { while (rows.size > MEMORY_CACHE_CAPACITY) { const oldest = rows.keys().next().value; if (oldest === undefined) break; rows.delete(oldest); } }; return KeyValueStore.makeStringOnly({ get: (key) => Effect.map(Clock.currentTimeMillis, (now) => { const entry = rows.get(key); if (entry === undefined) return undefined; if (entry.expiresAt <= now) { rows.delete(key); return undefined; } // LRU touch: move to the back of the insertion order. rows.delete(key); rows.set(key, entry); return entry.value; }), set: (key, value) => Effect.map(Clock.currentTimeMillis, (now) => { evictExpired(now); rows.delete(key); rows.set(key, { value, expiresAt: now + MEMORY_CACHE_TTL_MS }); evictOverCapacity(); }), remove: (key) => Effect.sync(() => { rows.delete(key); }), clear: Effect.sync(() => { rows.clear(); }), size: Effect.map(Clock.currentTimeMillis, (now) => { evictExpired(now);