277 lines · 10.6 KB · TypeScript
RawDownload
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.<integration>.<owner>.<connection>.<tool>` * 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>()(  "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>()(  "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>()(  "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>()(  "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>()("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>()(  "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>()(  "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>()(  "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>()(  "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>()(  "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>()(  "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>()(  "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>()(  "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>()(  "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;