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}`; }} /** A connection create or workspace-level mutation (an `owner: "org"` row, or * the tenant-shared integration catalog) was attempted on an executor bound * with `orgWrites: "denied"` — a member who may USE workspace resources but * not configure them. Reads, tool execution, and operational writes (token * refresh, catalog re-sync) are unaffected; only the user-intent settings * surfaces raise this. */export class OrgWriteDeniedError extends Schema.TaggedErrorClass<OrgWriteDeniedError>()( "OrgWriteDeniedError", {}, { httpApiStatus: 403 }, ) implements UserActionableError{ readonly __executorUserActionable = true; readonly code = "org_write_denied"; override get message(): string { return "Adding connections or changing workspace settings requires a workspace admin."; } get userMessage(): string { return this.message; }} 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 /** The tool row outlived its integration (an orphan the catalog no longer * lists), so there is no plugin config to invoke it against. */ | IntegrationNotFoundError | CredentialProviderNotRegisteredError | CredentialResolutionError | ElicitationDeclinedError | StorageFailure; /** Convenience union spanning every typed error the SDK raises. */export type ExecutorError = | ExecuteError | IntegrationRemovalNotAllowedError | ArtifactNotFoundError;
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}`;
}
}
/** A connection create or workspace-level mutation (an `owner: "org"` row, or
* the tenant-shared integration catalog) was attempted on an executor bound
* with `orgWrites: "denied"` — a member who may USE workspace resources but
* not configure them. Reads, tool execution, and operational writes (token
* refresh, catalog re-sync) are unaffected; only the user-intent settings
* surfaces raise this. */
export class OrgWriteDeniedError
extends Schema.TaggedErrorClass<OrgWriteDeniedError>()(
"OrgWriteDeniedError",
{},
{ httpApiStatus: 403 },
)
implements UserActionableError
{
readonly __executorUserActionable = true;
readonly code = "org_write_denied";
override get message(): string {
return "Adding connections or changing workspace settings requires a workspace admin.";
}
get userMessage(): string {
return this.message;
}
}
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
/** The tool row outlived its integration (an orphan the catalog no longer
* lists), so there is no plugin config to invoke it against. */
| IntegrationNotFoundError
| CredentialProviderNotRegisteredError
| CredentialResolutionError
| ElicitationDeclinedError
| StorageFailure;
/** Convenience union spanning every typed error the SDK raises. */
export type ExecutorError =
| ExecuteError
| IntegrationRemovalNotAllowedError
| ArtifactNotFoundError;