feat: enforce tagged error messages
This commit is contained in:
parent
cf80b5c470
commit
b30440ec26
31 changed files with 466 additions and 47 deletions
|
|
@ -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) })))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue