import { Schema } from "effect"; import { ElicitationDeclinedError } from "./elicitation"; import type { StorageFailure } from "./fuma-runtime"; import { ArtifactId, ConnectionName, IntegrationSlug, Owner, ProviderKey, ToolAddress, } from "./ids"; export interface UserActionableError { readonly __executorUserActionable: true; readonly userMessage: string; readonly code: string; } export const isUserActionableError = (value: unknown): value is UserActionableError => typeof value === "object" && value !== null && "__executorUserActionable" in value && value.__executorUserActionable === true && "userMessage" in value && typeof value.userMessage === "string" && value.userMessage.length > 0 && "code" in value && typeof value.code === "string" && value.code.length > 0; /* The failure set the SDK surfaces. `execute`'s invoke failures are ported from * v1 but re-keyed by `address` (the full `tools....` * handle) instead of an opaque tool id. Storage failures reuse fuma-runtime's * `StorageError`/`StorageConnectionError`/`UniqueViolationError` * (`StorageFailure`) — not redefined here. */ // --------------------------------------------------------------------------- // Tool lifecycle // --------------------------------------------------------------------------- /* Tagged errors without an explicit `message` field define a `message` getter: * `Schema.TaggedErrorClass` instances are real Errors with `message: ""`, and * an empty message propagates everywhere errors are rendered — span * status.message in the tracer, Cause.pretty output, log lines — leaving the * failure unlabeled in telemetry. The getter is derived from the schema fields * (not an own property), so encoding/serialization is unaffected. */ export class ToolNotFoundError extends Schema.TaggedErrorClass()( "ToolNotFoundError", { address: ToolAddress, suggestions: Schema.optional(Schema.Array(ToolAddress)), /** Why the address did not resolve, when something more useful than the * address is known — a connection that produced no tools, say. Optional: * an ordinary unknown tool name has nothing to add. */ reason: Schema.optional(Schema.String), }, ) { override get message(): string { return this.reason === undefined ? `Tool not found: ${this.address}` : `Tool not found: ${this.address} — ${this.reason}`; } } export class ToolInvocationError extends Schema.TaggedErrorClass()( "ToolInvocationError", { address: ToolAddress, message: Schema.String, cause: Schema.optional(Schema.Unknown), }, ) {} /** Tool invocation was rejected because a workspace `tool_policy` rule with * `action: "block"` matched. `pattern` is the matched policy pattern. */ export class ToolBlockedError extends Schema.TaggedErrorClass()( "ToolBlockedError", { address: ToolAddress, pattern: Schema.String, }, ) { override get message(): string { return `Tool blocked by policy "${this.pattern}": ${this.address}`; } } /** Tool row exists but its owning plugin isn't loaded in this executor config. */ export class PluginNotLoadedError extends Schema.TaggedErrorClass()( "PluginNotLoadedError", { address: ToolAddress, pluginId: Schema.String, }, ) { override get message(): string { return `Plugin "${this.pluginId}" is not loaded for tool: ${this.address}`; } } /** Tool was found but its owning plugin has no `invokeTool` handler. */ export class NoHandlerError extends Schema.TaggedErrorClass()("NoHandlerError", { address: ToolAddress, pluginId: Schema.String, }) { override get message(): string { return `Plugin "${this.pluginId}" has no invokeTool handler for tool: ${this.address}`; } } // --------------------------------------------------------------------------- // Integration / connection lifecycle // --------------------------------------------------------------------------- export class IntegrationNotFoundError extends Schema.TaggedErrorClass()( "IntegrationNotFoundError", { slug: IntegrationSlug }, ) { override get message(): string { return `Integration not found: ${this.slug}`; } } /** An "add integration" operation targeted a slug (namespace) that is already * registered. The core `integrations.register` primitive upserts by design * (for idempotent boot re-registration); add-operation layers gate on this to * prevent silently clobbering an existing integration's tools, connections, * and policies. */ export class IntegrationAlreadyExistsError extends Schema.TaggedErrorClass()( "IntegrationAlreadyExistsError", { slug: IntegrationSlug }, { httpApiStatus: 409 }, ) { override get message(): string { return `Integration already exists: ${this.slug}`; } } /** `integrations.remove` was called on an integration declared statically by a * plugin at startup (`canRemove: false`). */ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass()( "IntegrationRemovalNotAllowedError", { slug: IntegrationSlug }, ) { override get message(): string { return `Integration cannot be removed (declared statically by a plugin): ${this.slug}`; } } export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "ConnectionNotFoundError", { owner: Owner, integration: IntegrationSlug, name: ConnectionName, }, ) { override get message(): string { return `Connection not found: ${this.integration}.${this.owner}.${this.name}`; } } /** `connections.create` targeted a (owner, integration, name) that already has * a saved connection. Creating is never a replace: a silent upsert would * clobber the existing credential reference (and, for pasted values, the * stored secret itself). Rotate a credential by removing and re-adding the * connection, or pick a different name. OAuth re-connects go through * `mintOAuthConnection`, which intentionally re-stamps the same row. */ export class ConnectionAlreadyExistsError extends Schema.TaggedErrorClass()( "ConnectionAlreadyExistsError", { owner: Owner, integration: IntegrationSlug, name: ConnectionName, }, { httpApiStatus: 409 }, ) { override get message(): string { return `A connection named "${this.name}" already exists for ${this.integration} (${this.owner}). Choose a different name, or remove the existing connection first.`; } } /** A connection create request was rejected before anything was written: the * input is structurally invalid (no credential inputs for a credentialed * template, mixed pasted/external origins, …) or targets owner `user` in a * context that has no user subject. The message says which — it is safe to * show to the caller. */ export class InvalidConnectionInputError extends Schema.TaggedErrorClass()( "InvalidConnectionInputError", { message: Schema.String }, ) {} /** A connection references a credential provider key that isn't registered on * the executor. */ export class CredentialProviderNotRegisteredError extends Schema.TaggedErrorClass()( "CredentialProviderNotRegisteredError", { provider: ProviderKey }, ) { override get message(): string { return `Credential provider not registered: ${this.provider}`; } } /** A connection's value could not be resolved — the provider returned nothing, * or an OAuth token refresh failed and the user must re-auth. */ export class CredentialResolutionError extends Schema.TaggedErrorClass()( "CredentialResolutionError", { owner: Owner, integration: IntegrationSlug, name: ConnectionName, message: Schema.String, /** True when the stored grant is permanently invalid and the user must * sign in again (RFC 6749 §5.2 invalid_grant and friends). */ reauthRequired: Schema.optional(Schema.Boolean), /** The authorization server's RFC 6749 §5.2 error code (`invalid_grant`, * `invalid_client`, …), when the failure came from a token-endpoint * refusal. A typed field rather than message text so telemetry and * classification read it structurally — the misclassification where * `invalid_client` (rotated app secret, fleet-wide) surfaced as a vague * "degraded" was only findable by grepping persisted message strings. */ oauthErrorCode: Schema.optional(Schema.String), /** True when an enterprise identity provider declined to authorize this * connection under administrator policy. Distinct from `reauthRequired` * because signing in again cannot help, and — critically — the client must * NOT offer the ordinary per-server OAuth flow as an alternative route: * that would let the user walk around the policy the IdP just enforced. */ blockedByAdmin: Schema.optional(Schema.Boolean), /** True when the failure is that no usable credential material EXISTS on * our side (no stored refresh token, an unresolvable provider item, a * deregistered OAuth client) — nothing was sent upstream and no * authorization server refused anything. Structural, so health telemetry * can separate "missing material" from "refresh rejected" without * reading the message. */ credentialMissing: Schema.optional(Schema.Boolean), }, ) {} // --------------------------------------------------------------------------- // Artifacts // --------------------------------------------------------------------------- /** No artifact with this id is visible to the bound owner scope — it was never * saved, was deleted, or belongs to another subject. */ export class ArtifactNotFoundError extends Schema.TaggedErrorClass()( "ArtifactNotFoundError", { id: ArtifactId }, ) { override get message(): string { return `Artifact not found: ${this.id}`; } } // --------------------------------------------------------------------------- // Union — the failure channel of `execute`. // --------------------------------------------------------------------------- export type ExecuteError = | ToolNotFoundError | ToolInvocationError | ToolBlockedError | PluginNotLoadedError | NoHandlerError | ConnectionNotFoundError | CredentialProviderNotRegisteredError | CredentialResolutionError | ElicitationDeclinedError | StorageFailure; /** Convenience union spanning every typed error the SDK raises. */ export type ExecutorError = | ExecuteError | IntegrationNotFoundError | IntegrationRemovalNotAllowedError | ArtifactNotFoundError;