feat: enforce tagged error messages

This commit is contained in:
Aiden Cline 2026-06-23 16:56:23 -05:00
commit b30440ec26
31 changed files with 466 additions and 47 deletions

View file

@ -5,6 +5,7 @@ import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import { State } from "./state"
import { errorMessage } from "./util/error"
type SDK = any
@ -123,7 +124,11 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
providerID: ProviderV2.ID,
cause: Schema.Defect(),
}) {}
}) {
override get message() {
return `Failed to initialize AI SDK provider ${this.providerID}: ${errorMessage(this.cause)}`
}
}
function initError(providerID: ProviderV2.ID) {
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))

View file

@ -30,7 +30,11 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<Des
expected: ProjectV2.ID,
actual: ProjectV2.ID,
},
) {}
) {
override get message() {
return `Destination project ${this.actual} does not match session project ${this.expected}`
}
}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,

View file

@ -30,11 +30,19 @@ export interface RemoveInput {
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
path: Schema.String,
}) {}
}) {
override get message() {
return `File changed since it was read: ${this.path}`
}
}
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
path: Schema.String,
}) {}
}) {
override get message() {
return `File already exists: ${this.path}`
}
}
export interface WriteResult {
readonly operation: "write"

View file

@ -7,7 +7,11 @@ import { FileSystem } from "./filesystem"
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"Image.ResizerUnavailableError",
{},
) {}
) {
override get message() {
return "Image resizer is unavailable"
}
}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("Image.DecodeError", {
resource: Schema.String,

View file

@ -169,11 +169,20 @@ export type AttemptStatus = typeof AttemptStatus.Type
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
attemptID: AttemptID,
}) {}
}) {
override get message() {
return `Authorization code required for OAuth attempt ${this.attemptID}`
}
}
export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Integration.Authorization", {
cause: Schema.Defect(),
}) {}
}) {
override get message() {
const detail = this.cause instanceof Error ? this.cause.message : String(this.cause)
return `Integration authorization failed${detail ? `: ${detail}` : ""}`
}
}
export type Error = CodeRequiredError | AuthorizationError

View file

@ -23,7 +23,13 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]),
}) {}
}) {
override get message() {
if (this.reason === "relative_escape") return `Relative path escapes the location: ${this.path}`
if (this.reason === "location_escape") return `Path resolves outside the location: ${this.path}`
return `Path has a non-directory ancestor: ${this.path}`
}
}
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"

View file

@ -11,12 +11,18 @@ import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform"
import { makeRuntime } from "./effect/runtime"
import { NpmConfig } from "./npm-config"
import { errorMessage } from "./util/error"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
}) {
override get message() {
const detail = this.cause === undefined ? undefined : errorMessage(this.cause)
return `Failed to install ${this.add?.join(", ") || "dependencies"} in ${this.dir}${detail ? `: ${detail}` : ""}`
}
}
export interface EntryPoint {
readonly directory: string

View file

@ -83,19 +83,36 @@ export const Event = {
}),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {
override get message() {
return "The user rejected this permission request"
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
}) {}
}) {
override get message() {
return `The user rejected this permission request with feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
rules: PermissionSchema.Ruleset,
}) {}
}) {
override get message() {
if (this.rules.length === 0) return "Permission denied by configured rules"
return `Permission denied by configured rules: ${this.rules.map((rule) => `${rule.action} ${rule.resource}`).join(", ")}`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
requestID: ID,
}) {}
}) {
override get message() {
return `Permission request not found: ${this.requestID}`
}
}
export type Error = DeniedError | RejectedError | CorrectedError

View file

@ -58,32 +58,56 @@ export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"ProjectCopy.SourceDirectoryNotFoundError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Project copy source directory not found: ${this.directory}`
}
}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"ProjectCopy.DestinationExistsError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Project copy destination already exists: ${this.directory}`
}
}
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"ProjectCopy.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Project copy directory is unavailable: ${this.directory}`
}
}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"ProjectCopy.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Invalid project copy directory: ${this.directory}`
}
}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"ProjectCopy.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
) {
override get message() {
return `Project copy strategy is unavailable: ${this.strategy}`
}
}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"ProjectCopy.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
) {
override get message() {
return `Project copy strategy is already registered: ${this.strategy}`
}
}
export type Error =
| SourceDirectoryNotFoundError

View file

@ -94,11 +94,19 @@ export type Attachment = {
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
}) {
override get message() {
return `PTY session not found: ${this.ptyID}`
}
}
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
ptyID: PtyID,
}) {}
}) {
override get message() {
return `PTY session has exited: ${this.ptyID}`
}
}
export const Event = {
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),

View file

@ -87,7 +87,11 @@ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Que
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("QuestionV2.NotFoundError", {
requestID: ID,
}) {}
}) {
override get message() {
return `Question request not found: ${this.requestID}`
}
}
export interface AskInput {
readonly sessionID: SessionSchema.ID

View file

@ -83,21 +83,33 @@ type CompactInput = {
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: SessionSchema.ID,
}) {}
}) {
override get message() {
return `Session not found: ${this.sessionID}`
}
}
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
},
) {}
) {
override get message() {
return `Session ${this.operation} is not available yet`
}
}
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
}) {
override get message() {
return `Prompt message ${this.messageID} conflicts with an existing durable record in session ${this.sessionID}`
}
}
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError

View file

@ -46,6 +46,7 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic
return row === undefined ? undefined : fromRow(row)
})
// oxlint-disable-next-line opencode/tagged-error-message -- internal defect sentinel for inconsistent projections
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
id: SessionMessage.ID,
}) {}

View file

@ -16,11 +16,19 @@ export namespace EffectFlock {
export class LockTimeoutError extends Schema.TaggedErrorClass<LockTimeoutError>()("LockTimeoutError", {
key: Schema.String,
}) {}
}) {
override get message() {
return `Timed out acquiring lock: ${this.key}`
}
}
export class LockCompromisedError extends Schema.TaggedErrorClass<LockCompromisedError>()("LockCompromisedError", {
detail: Schema.String,
}) {}
}) {
override get message() {
return `Lock was compromised: ${this.detail}`
}
}
class ReleaseError extends Schema.TaggedErrorClass<ReleaseError>()("ReleaseError", {
detail: Schema.String,
@ -32,6 +40,7 @@ export namespace EffectFlock {
}
/** Internal: signals "lock is held, retry later". Never leaks to callers. */
// oxlint-disable-next-line opencode/tagged-error-message -- internal retry sentinel for lock contention
class NotAcquired extends Schema.TaggedErrorClass<NotAcquired>()("NotAcquired", {}) {}
export type LockError = LockTimeoutError | LockCompromisedError

View file

@ -91,6 +91,10 @@ export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("Permiss
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: ID,
}) {}
}) {
override get message() {
return `Permission request not found: ${this.requestID}`
}
}
export type Error = DeniedError | RejectedError | CorrectedError