From 4a6ba489c457f7a1783f476c7b4678a5585f66d8 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 7 Oct 2026 18:57:14 +0200 Subject: [PATCH 1/6] fix(cli): classify new local stack failures in telemetry New-stack command failures reached PostHog as one fingerprint per error tag with category `unknown`, because every `@supabase/stack` failure crossed the RPC boundary as message text and each CLI reason shared a fingerprint. `@supabase/stack` now derives a closed failure kind from the typed cause chain and carries it on `StackError`, composition outcomes, and the shutdown response as an open string so older releases still decode it. The CLI maps the stack reason and kind to actionability through a typed `StackError` adapter, gives every new-stack command reason its own fingerprint suffix, and keeps the stack cause behind stack-backed db and functions errors. A port conflict is user-actionable only when the port was set in config.toml or a SUPABASE_*_PORT override; auto-assigned, saved, native backend and engine-chosen ports report `port_allocation`. Co-Authored-By: Claude Opus 5.5 --- .../db-bootstrap/local-db-running.ts | 4 +- .../db-bootstrap/reset-local-database.ts | 23 +- .../command-internal/stack-local-database.ts | 21 +- .../src/command-internal/test-db.errors.ts | 4 +- .../src/command-internal/test-db.handler.ts | 3 +- apps/cli/src/commands/db/diff/diff.errors.ts | 8 +- apps/cli/src/commands/db/diff/diff.handler.ts | 1 + apps/cli/src/commands/db/dump/dump.errors.ts | 4 +- apps/cli/src/commands/db/dump/dump.handler.ts | 4 +- .../schema/declarative/declarative.errors.ts | 9 +- .../schema/declarative/sync/sync.handler.ts | 3 +- .../stack/destroy/destroy.errors.ts | 25 +- .../stack/destroy/destroy.handler.ts | 2 +- .../experimental/stack/list/list.errors.ts | 4 +- .../experimental/stack/logs/logs.errors.ts | 16 +- .../experimental/stack/logs/logs.handler.ts | 6 +- .../stack/prepare/prepare.errors.ts | 43 ++- .../stack/prepare/prepare.integration.test.ts | 10 +- .../stack/restart/restart.errors.ts | 29 +- .../stack/restart/restart.handler.ts | 2 +- .../stack/stack-errors.unit.test.ts | 129 +++++++++ .../experimental/stack/stack.shared.ts | 4 +- .../experimental/stack/start/start.errors.ts | 32 +-- .../stack/start/start.errors.unit.test.ts | 15 - .../experimental/stack/start/start.handler.ts | 2 +- .../stack/status/status.errors.ts | 17 +- .../stack/status/status.handler.ts | 32 +-- .../experimental/stack/stop/stop.errors.ts | 14 +- .../experimental/stack/stop/stop.handler.ts | 7 +- .../stack/stop/stop.integration.test.ts | 2 +- .../commands/functions/serve/serve.errors.ts | 19 +- .../functions/serve/serve.stack.handler.ts | 30 +- .../shared/telemetry/error-actionability.ts | 133 ++++++++- packages/stack/src/Artifacts.ts | 9 +- packages/stack/src/FailureKind.ts | 202 +++++++++++++ packages/stack/src/FailureKind.unit.test.ts | 269 ++++++++++++++++++ packages/stack/src/HostProcess.ts | 28 +- packages/stack/src/Ports.integration.test.ts | 3 + packages/stack/src/Ports.ts | 21 +- packages/stack/src/PromiseClient.ts | 5 +- packages/stack/src/Rpc.ts | 17 +- packages/stack/src/Rpc.unit.test.ts | 41 ++- packages/stack/src/Service.ts | 3 + .../stack/src/StackHost.integration.test.ts | 17 +- packages/stack/src/StackHost.ts | 13 +- ...oy.runtime-unavailable.integration.test.ts | 3 + packages/stack/src/effect.ts | 78 +++-- packages/stack/src/internal/host-process.ts | 3 + .../src/runtime/Container.integration.test.ts | 61 +++- packages/stack/src/runtime/Container.ts | 55 +++- packages/stack/src/runtime/Session.ts | 8 +- packages/stack/src/services/Database.ts | 7 +- packages/stack/src/services/ProcessRecipe.ts | 5 + .../stack/src/shutdown.integration.test.ts | 1 + 54 files changed, 1248 insertions(+), 258 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts delete mode 100644 apps/cli/src/commands/experimental/stack/start/start.errors.unit.test.ts create mode 100644 packages/stack/src/FailureKind.ts create mode 100644 packages/stack/src/FailureKind.unit.test.ts diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts index bc873b6c43..854aa19d6a 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts @@ -5,6 +5,7 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; @@ -26,12 +27,13 @@ export class LocalDbRunningError extends Data.TaggedError("LocalDbRunningError") readonly daemonDown?: boolean; /** Set when the failure is a daemon-connection error. */ readonly suggestion?: string; + readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { if (this.daemonDown === true) { return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; } - return actionability.startStack; + return causeDeclaration(this.cause) ?? actionability.startStack; } } diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index cb61d07cf2..4848a43f78 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -23,6 +23,7 @@ import { Output } from "../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; @@ -65,9 +66,10 @@ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunni class ResetLocalDbFailedError extends Data.TaggedError("ResetLocalDbFailedError")<{ readonly message: string; readonly suggestion?: string; + readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbConnection; + return causeDeclaration(this.cause) ?? actionability.dbConnection; } } @@ -92,7 +94,8 @@ const notRunning = () => message: `${aqua("supabase start")} is not running.`, }); -const resetFailed = (message: string) => new ResetLocalDbFailedError({ message }); +const resetFailed = (message: string, cause?: unknown) => + new ResetLocalDbFailedError({ message, ...(cause === undefined ? {} : { cause }) }); const suggestionOf = (error: unknown): string | undefined => error instanceof StackStorageUnavailableError || error instanceof StackStorageCapabilityError @@ -159,16 +162,24 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); yield* Effect.gen(function* () { yield* opened.value.stack.composition.stop.pipe( - Effect.mapError((cause) => resetFailed(`failed to stop local stack: ${cause.message}`)), + Effect.mapError((cause) => + resetFailed(`failed to stop local stack: ${cause.message}`, cause), + ), ); yield* opened.value.database.resetData.pipe( - Effect.mapError((cause) => resetFailed(`failed to reset local database: ${cause.message}`)), + Effect.mapError((cause) => + resetFailed(`failed to reset local database: ${cause.message}`, cause), + ), ); yield* opened.value.database.start.pipe( - Effect.mapError((cause) => resetFailed(`failed to start local database: ${cause.message}`)), + Effect.mapError((cause) => + resetFailed(`failed to start local database: ${cause.message}`, cause), + ), ); yield* opened.value.database.ready.pipe( - Effect.mapError((cause) => resetFailed(`failed to ready local database: ${cause.message}`)), + Effect.mapError((cause) => + resetFailed(`failed to ready local database: ${cause.message}`, cause), + ), ); }).pipe(Effect.withSpan("DbBootstrap.resetStackDatabase")); yield* initializeStackDatabase({ diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 613bb47d10..b6a3641ba8 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -36,8 +36,15 @@ const cacheRoot = (settings: Settings, path: Path.Path) => path.join(settings.supabaseHome, "cache", "stack"); const notRunning = (message = "The local stack is not running.") => new LocalDbRunningError({ message }); +const unavailable = (cause: { readonly message: string }) => + cause instanceof LocalDbRunningError + ? cause + : new LocalDbRunningError({ message: cause.message, cause }); const startFailed = (cause: { readonly message: string }) => - new LocalDbRunningError({ message: `failed to start local database: ${cause.message}` }); + new LocalDbRunningError({ + message: `failed to start local database: ${cause.message}`, + cause, + }); type StackInstances = Effect.Success; @@ -98,7 +105,7 @@ export const stackOpenProjectBy = (onFailure: (cause: { readonly message: str openProjectStack().pipe(Effect.mapError(onFailure)); /** Returns the project stack only when its primary database is healthy. */ -export const stackOpenReadyProject = stackOpenProjectBy((cause) => notRunning(cause.message)).pipe( +export const stackOpenReadyProject = stackOpenProjectBy(unavailable).pipe( Effect.flatMap((opened) => Option.isNone(opened) ? Effect.succeed(Option.none()) : databaseReady(opened.value), ), @@ -179,13 +186,9 @@ const stackLocalDatabaseUrl: Effect.Effect< LocalDbRunningError, CommandSettings | StackApi | Path.Path > = Effect.gen(function* () { - const opened = yield* stackOpenReadyProject.pipe( - Effect.mapError((cause) => notRunning(cause.message)), - ); + const opened = yield* stackOpenReadyProject.pipe(Effect.mapError(unavailable)); if (Option.isNone(opened)) return yield* notRunning(); - const status = yield* opened.value.database.status.pipe( - Effect.mapError((cause) => notRunning(cause.message)), - ); + const status = yield* opened.value.database.status.pipe(Effect.mapError(unavailable)); const endpoint = status.endpoints.find(({ name }) => name === "sql"); if (status.config.service !== "database") return yield* notRunning("The local stack primary service is not a database."); @@ -209,7 +212,7 @@ export const stackLocalDatabaseConn: Effect.Effect< ? Effect.fail(notRunning("failed to parse stack database URL")) : Effect.succeed(conn); }), - Effect.mapError((cause) => notRunning(cause.message)), + Effect.mapError(unavailable), ); /** Starts or resumes the primary database, initializing the catalog on first creation. */ diff --git a/apps/cli/src/command-internal/test-db.errors.ts b/apps/cli/src/command-internal/test-db.errors.ts index 4ba24afb41..62b81c4820 100644 --- a/apps/cli/src/command-internal/test-db.errors.ts +++ b/apps/cli/src/command-internal/test-db.errors.ts @@ -2,6 +2,7 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; @@ -21,9 +22,10 @@ export class TestDbEnablePgtapError extends Data.TaggedError("TestDbEnablePgtapE */ export class TestDbRunError extends Data.TaggedError("TestDbRunError")<{ readonly message: string; + readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbFinding; + return causeDeclaration(this.cause) ?? actionability.dbFinding; } } diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index f5010ec342..4c886241fa 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -311,7 +311,8 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { }) .pipe( Effect.mapError( - (cause) => new TestDbRunError({ message: `pg_prove run failed: ${cause.message}` }), + (cause) => + new TestDbRunError({ message: `pg_prove run failed: ${cause.message}`, cause }), ), ); } diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index 134c9e15ba..22c550cd76 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -1,6 +1,7 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; @@ -74,14 +75,15 @@ export class DbDiffDbNotRunningError extends Data.TaggedError("DbDiffDbNotRunnin readonly message: string; readonly daemonDown?: boolean; readonly suggestion?: string; + readonly cause?: unknown; }> { // Must stay character-identical to `LocalDbRunningError`'s classification // (`legacy-db-bootstrap`'s equivalent local-db-not-running check). The two are kept in sync by // hand rather than shared, since they serve separate parity targets. get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.daemonDown === true - ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } - : actionability.startStack; // same preset `reset-local-database.ts` uses + if (this.daemonDown === true) + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + return causeDeclaration(this.cause) ?? actionability.startStack; // same preset `reset-local-database.ts` uses } } diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 85a048de5a..06e162ac42 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -303,6 +303,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: cause.message, daemonDown: cause.daemonDown, suggestion: cause.suggestion, + cause, }), ), ); diff --git a/apps/cli/src/commands/db/dump/dump.errors.ts b/apps/cli/src/commands/db/dump/dump.errors.ts index aa4326cc15..62bc55ba0e 100644 --- a/apps/cli/src/commands/db/dump/dump.errors.ts +++ b/apps/cli/src/commands/db/dump/dump.errors.ts @@ -1,6 +1,7 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; @@ -48,8 +49,9 @@ export class DbDumpRunError extends Data.TaggedError("DbDumpRunError")<{ readonly message: string; /** Printed on stderr after the error (IPv6 pooler guidance, native client hint). */ readonly suggestion?: string; + readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbConnection; + return causeDeclaration(this.cause) ?? actionability.dbConnection; } } diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index c4d32a5dc9..fab479fd01 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -194,7 +194,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { const managedProject = backend.kind === "stack" && connType === "local" ? yield* stackOpenReadyProject.pipe( - Effect.mapError((cause) => new DbDumpRunError({ message: cause.message })), + Effect.mapError((cause) => new DbDumpRunError({ message: cause.message, cause })), Effect.map(Option.getOrUndefined), ) : undefined; @@ -288,7 +288,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { ? undefined : parseConnectionString( yield* managedProject.database.credentials({ from: "runtime" }).pipe( - Effect.mapError((cause) => new DbDumpRunError({ message: cause.message })), + Effect.mapError((cause) => new DbDumpRunError({ message: cause.message, cause })), Effect.map((credentials) => credentials.databaseUrl ?? ""), ), ); diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts index 8c3f4541a7..fe3adc6691 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -2,6 +2,7 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../../../shared/telemetry/error-actionability.ts"; @@ -119,10 +120,16 @@ export class DeclarativeApplyError extends Data.TaggedError("DeclarativeApplyErr * hint) when the local-reset recovery path fails, so the wrap doesn't drop it. */ readonly suggestion?: string; + readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { if (this.connect === true) { - return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; + return ( + causeDeclaration(this.cause) ?? { + ...actionability.dbConnection, + fingerprint_suffix: "connect", + } + ); } return actionability.dbFinding; } diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 508ecaad0f..eea2d51858 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -584,7 +584,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f backend.kind === "stack" ? yield* stackLocalDatabaseConn.pipe( Effect.mapError( - (error) => new DeclarativeApplyError({ message: error.message, connect: true }), + (error) => + new DeclarativeApplyError({ message: error.message, connect: true, cause: error }), ), ) : { diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts index 0dc8036870..174921a433 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.errors.ts @@ -1,19 +1,14 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStackDestroyError")<{ - readonly reason: - | "flags" - | "confirmation" - | "cancelled" - | "invalid-config" - | "runtime" - | "lifecycle" - | "unknown"; + readonly reason: "flags" | "confirmation" | "cancelled" | "invalid-config" | "stack"; readonly message: string; readonly detail?: string; readonly suggestion?: string; @@ -22,17 +17,15 @@ export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStac get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "flags": + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "confirmation": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "confirmation" }; case "cancelled": - return actionability.cancelled; + return { ...actionability.cancelled, fingerprint_suffix: "cancelled" }; case "invalid-config": - case "lifecycle": - return actionability.invalidConfig; - case "runtime": - return actionability.dockerNotRunning; - case "unknown": - return actionability.unknown; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 4e6cb9ebc7..7ca01440a2 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -29,7 +29,7 @@ const mapTargetError = (error: StackTargetError, detail?: string) => const destroyError = (cause: StackError) => new StackCommandDestroyError({ - reason: "unknown", + reason: "stack", message: cause.message, cause, }); diff --git a/apps/cli/src/commands/experimental/stack/list/list.errors.ts b/apps/cli/src/commands/experimental/stack/list/list.errors.ts index 1d1e1dad5c..c10875863c 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.errors.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.errors.ts @@ -12,6 +12,8 @@ export class StackCommandListError extends Data.TaggedError("ExperimentalStackLi readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.reason === "flags" ? actionability.provideFlags : actionability.invalidConfig; + return this.reason === "flags" + ? { ...actionability.provideFlags, fingerprint_suffix: "flags" } + : { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; } } diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts index 783c0656df..ab35ecdbaa 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.errors.ts @@ -1,12 +1,14 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandLogsError extends Data.TaggedError("ExperimentalStackLogsError")<{ - readonly reason: "flags" | "invalid-config" | "lifecycle" | "impossible-state" | "unknown"; + readonly reason: "flags" | "invalid-config" | "lifecycle" | "stack"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -14,15 +16,13 @@ export class StackCommandLogsError extends Data.TaggedError("ExperimentalStackLo get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "flags": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "invalid-config": - return actionability.invalidConfig; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "lifecycle": - return actionability.invalidConfig; - case "impossible-state": - return actionability.impossibleState; - case "unknown": - return actionability.unknown; + return { ...actionability.invalidConfig, fingerprint_suffix: "lifecycle" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index 779791e276..000e2b748c 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -32,7 +32,11 @@ const targetError = (cause: StackTargetError) => cause, }); const logsError = (cause: { readonly message: string }) => - new StackCommandLogsError({ reason: "unknown", message: cause.message, cause }); + new StackCommandLogsError({ + reason: "stack", + message: cause.message, + cause, + }); type SavedInstance = SavedStack["instances"][number]; interface Selected { diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts index 8fea9be28b..5d149fe83f 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts @@ -1,13 +1,15 @@ -import { StackError } from "@supabase/stack/effect"; -import { Data, Schema } from "effect"; +import type { StackError } from "@supabase/stack/effect"; +import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandPrepareError extends Data.TaggedError("ExperimentalStackPrepareError")<{ - readonly reason: "flags" | "invalid-config" | "runtime" | "artifact" | "lifecycle" | "unknown"; + readonly reason: "flags" | "invalid-config" | "runtime" | "lifecycle" | "stack"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -15,36 +17,23 @@ export class StackCommandPrepareError extends Data.TaggedError("ExperimentalStac get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "flags": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "invalid-config": - case "lifecycle": - return actionability.invalidConfig; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "runtime": - return actionability.dockerNotRunning; - case "artifact": - return actionability.externalNetwork; - case "unknown": - return actionability.unknown; + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "lifecycle": + return { ...actionability.invalidConfig, fingerprint_suffix: "lifecycle" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } /** Maps an internal stack failure to the prepare command's stable error boundary. */ -export const stackPrepareError = (error: unknown): StackCommandPrepareError => { - const message = - typeof error === "object" && - error !== null && - "message" in error && - typeof error.message === "string" - ? error.message - : String(error); - return new StackCommandPrepareError({ - reason: Schema.is(StackError)(error) - ? error.operation === "prepareService" - ? "artifact" - : "unknown" - : "unknown", - message, +export const stackPrepareError = (error: StackError): StackCommandPrepareError => + new StackCommandPrepareError({ + reason: "stack", + message: error.message, cause: error, }); -}; diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index a047287773..10099a1ccc 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -83,7 +83,13 @@ const makeFixture = (root: string, options: FixtureOptions = {}) => { }).pipe( Effect.andThen(() => failPreparation - ? Effect.fail(new StackError({ operation: "prepareService", message: "download failed" })) + ? Effect.fail( + new StackError({ + operation: "prepareService", + message: "download failed", + kind: "artifact-download", + }), + ) : Effect.void, ), ), @@ -283,7 +289,7 @@ describe("stack prepare", () => { Effect.tap((error) => Effect.sync(() => { expect(error.message).toContain("download failed"); - expect(error.reason).toBe("artifact"); + expect(error.reason).toBe("stack"); expect(fixture.destroyCount).toBe(1); expect(fixture.startCount).toBe(0); }), diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts b/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts index fd82d74ca4..0f3fd40781 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.errors.ts @@ -1,21 +1,14 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandRestartError extends Data.TaggedError("ExperimentalStackRestartError")<{ - readonly reason: - | "flags" - | "not-found" - | "invalid-config" - | "port" - | "lifecycle" - | "runtime" - | "registry" - | "artifact" - | "unknown"; + readonly reason: "flags" | "not-found" | "invalid-config" | "lifecycle" | "stack"; readonly message: string; readonly detail?: string; readonly suggestion?: string; @@ -24,19 +17,15 @@ export class StackCommandRestartError extends Data.TaggedError("ExperimentalStac get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "flags": + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "not-found": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "not_found" }; case "invalid-config": - case "port": + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "lifecycle": - return actionability.invalidConfig; - case "runtime": - return actionability.dockerNotRunning; - case "registry": - case "artifact": - return actionability.externalNetwork; - case "unknown": - return actionability.unknown; + return { ...actionability.invalidConfig, fingerprint_suffix: "lifecycle" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts index 25143db0b4..443c4f1c40 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -17,7 +17,7 @@ import type { StackRestartFlags } from "./restart.command.ts"; const runtimeError = (cause: StackError) => { const detail = failedOutcomesDetail(cause); return new StackCommandRestartError({ - reason: "unknown", + reason: "stack", message: cause.message, ...(detail === undefined ? {} : { detail }), cause, diff --git a/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts b/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts new file mode 100644 index 0000000000..4b37550a1c --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts @@ -0,0 +1,129 @@ +import { StackError } from "@supabase/stack/effect"; +import { describe, expect, it } from "vitest"; +import { LocalDbRunningError } from "../../../command-internal/db-bootstrap/local-db-running.ts"; +import { DbSetupError } from "../../../command-internal/db-bootstrap/db-setup.ts"; +import { TestDbRunError } from "../../../command-internal/test-db.errors.ts"; +import { DbDumpRunError } from "../../db/dump/dump.errors.ts"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { StackCommandDestroyError } from "./destroy/destroy.errors.ts"; +import { StackCommandStartError } from "./start/start.errors.ts"; +import { StackCommandStatusError } from "./status/status.errors.ts"; + +const classify = (error: unknown) => { + const { error_fingerprint, error_kind, error_category } = classifyCliErrorActionability(error); + return { error_fingerprint, error_kind, error_category }; +}; + +describe("new stack error telemetry", () => { + it("fingerprints each command reason separately", () => { + expect( + classify(new StackCommandStatusError({ reason: "not-found", message: "no stack" })), + ).toMatchObject({ error_fingerprint: "tag:ExperimentalStackStatusError:not_found" }); + expect( + classify(new StackCommandStatusError({ reason: "flags", message: "bad flags" })), + ).toMatchObject({ error_fingerprint: "tag:ExperimentalStackStatusError:flags" }); + expect(classify(new StackCommandStartError({ reason: "seed", message: "seed" }))).toEqual({ + error_fingerprint: "tag:ExperimentalStackStartError:seed_buckets", + error_kind: "user_actionable", + error_category: "invalid_config", + }); + }); + + it("treats only a configured port's conflict as user-actionable", () => { + const wrap = (kind: string) => + new StackCommandStartError({ + reason: "stack", + message: "x", + cause: new StackError({ operation: "startComposition", message: "x", kind }), + }); + + expect(classify(wrap("port-conflict"))).toEqual({ + error_fingerprint: "tag:ExperimentalStackStartError:port_conflict", + error_kind: "user_actionable", + error_category: "invalid_config", + }); + expect(classify(wrap("port-allocation"))).toEqual({ + error_fingerprint: "tag:ExperimentalStackStartError:port_allocation", + error_kind: "internal_bug", + error_category: "runtime_crash", + }); + }); + + it("prefers the client reason, then the kind, then a failed outcome's kind", () => { + const outcomes = new StackError({ + operation: "startComposition", + message: "x", + outcomes: [ + { id: "a", succeeded: true }, + { id: "b", succeeded: false, error: "y", kind: "health-timeout" }, + ], + }); + const reason = new StackError({ + operation: "shutdown", + message: "x", + reason: "release-mismatch", + kind: "owner-connection", + }); + + expect( + classify(new StackCommandDestroyError({ reason: "stack", message: "x", cause: outcomes })), + ).toEqual({ + error_fingerprint: "tag:ExperimentalStackDestroyError:health_timeout", + error_kind: "internal_bug", + error_category: "runtime_crash", + }); + expect( + classify(new StackCommandDestroyError({ reason: "stack", message: "x", cause: reason })), + ).toMatchObject({ error_fingerprint: "tag:ExperimentalStackDestroyError:release_mismatch" }); + }); + + it("reports a kind from a newer release as unclassified instead of failing", () => { + const cause = new StackError({ operation: "start", message: "x", kind: "from-the-future" }); + + expect(classify(new StackCommandStartError({ reason: "stack", message: "x", cause }))).toEqual({ + error_fingerprint: "tag:ExperimentalStackStartError:unclassified", + error_kind: "unknown", + error_category: "unknown", + }); + }); + + it("keeps the declaration of a wrapped CLI-owned error", () => { + const cause = new DbSetupError({ reason: "registry_pull", message: "pull failed" }); + + expect( + classify(new StackCommandStartError({ reason: "stack", message: "x", cause })), + ).toMatchObject({ error_fingerprint: "tag:ExperimentalStackStartError:registry_pull" }); + }); + + it("classifies a raw StackError and keeps it through stack-backed database wrappers", () => { + const cause = new StackError({ operation: "status", message: "x", kind: "state" }); + + expect(classify(cause)).toEqual({ + error_fingerprint: "tag:StackError:state_file", + error_kind: "user_actionable", + error_category: "invalid_config", + }); + expect(classify(new LocalDbRunningError({ message: "x", cause }))).toMatchObject({ + error_fingerprint: "tag:LocalDbRunningError:state_file", + }); + expect( + classify( + new DbDumpRunError({ + message: "x", + cause: new LocalDbRunningError({ message: "x", cause }), + }), + ), + ).toMatchObject({ error_fingerprint: "tag:DbDumpRunError:state_file" }); + expect(classify(new TestDbRunError({ message: "x", cause }))).toMatchObject({ + error_fingerprint: "tag:TestDbRunError:state_file", + }); + expect(classify(new TestDbRunError({ message: "exit 1" }))).toMatchObject({ + error_fingerprint: "tag:TestDbRunError", + error_category: "invalid_config", + }); + expect(classify(new LocalDbRunningError({ message: "not running" }))).toMatchObject({ + error_fingerprint: "tag:LocalDbRunningError", + error_category: "invalid_config", + }); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 6adc4fd71c..85a8ccef91 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -40,7 +40,9 @@ export class StackTargetError extends Data.TaggedError("ExperimentalStackTargetE readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.reason === "flags" ? actionability.provideFlags : actionability.invalidConfig; + return this.reason === "flags" + ? { ...actionability.provideFlags, fingerprint_suffix: "flags" } + : { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; } } diff --git a/apps/cli/src/commands/experimental/stack/start/start.errors.ts b/apps/cli/src/commands/experimental/stack/start/start.errors.ts index a8519f4909..15d7edea98 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.errors.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.errors.ts @@ -1,21 +1,14 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandStartError extends Data.TaggedError("ExperimentalStackStartError")<{ - readonly reason: - | "invalid-config" - | "flags" - | "runtime" - | "registry" - | "port" - | "artifact" - | "lifecycle" - | "seed" - | "unknown"; + readonly reason: "invalid-config" | "flags" | "runtime" | "lifecycle" | "seed" | "stack"; readonly message: string; readonly detail?: string; readonly suggestion?: string; @@ -24,22 +17,17 @@ export class StackCommandStartError extends Data.TaggedError("ExperimentalStackS get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "invalid-config": - return actionability.invalidConfig; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "flags": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "runtime": - return actionability.dockerNotRunning; - case "registry": - case "artifact": - return actionability.externalNetwork; - case "port": - return actionability.invalidConfig; + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; case "lifecycle": - return actionability.invalidConfig; + return { ...actionability.invalidConfig, fingerprint_suffix: "lifecycle" }; case "seed": - return actionability.seedBuckets; - case "unknown": - return actionability.unknown; + return { ...actionability.seedBuckets, fingerprint_suffix: "seed_buckets" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } diff --git a/apps/cli/src/commands/experimental/stack/start/start.errors.unit.test.ts b/apps/cli/src/commands/experimental/stack/start/start.errors.unit.test.ts deleted file mode 100644 index 593c63a62f..0000000000 --- a/apps/cli/src/commands/experimental/stack/start/start.errors.unit.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - actionability, - ErrorActionabilityId, -} from "../../../../shared/telemetry/error-actionability.ts"; -import { StackCommandStartError } from "./start.errors.ts"; - -describe("StackCommandStartError actionability", () => { - it("classifies a seed failure as recoverable with supabase seed buckets", () => { - const error = new StackCommandStartError({ reason: "seed", message: "x" }); - - expect(error[ErrorActionabilityId]).toEqual(actionability.seedBuckets); - }); -}); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 83df18a4f5..3c602375cf 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -109,7 +109,7 @@ const stackError = ( return service === undefined ? id : `${service} (${id})`; }); return new StackCommandStartError({ - reason: "unknown", + reason: "stack", message: cause.message, ...(detail === undefined ? {} : { detail }), cause, diff --git a/apps/cli/src/commands/experimental/stack/status/status.errors.ts b/apps/cli/src/commands/experimental/stack/status/status.errors.ts index 4f3c1d59d5..2cde783332 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.errors.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.errors.ts @@ -1,29 +1,32 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandStatusError extends Data.TaggedError("ExperimentalStackStatusError")<{ readonly message: string; - readonly reason: "flags" | "not-found" | "invalid-config" | "lifecycle" | "output" | "runtime"; + readonly reason: "flags" | "not-found" | "invalid-config" | "lifecycle" | "output" | "stack"; readonly suggestion?: string; readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "flags": + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "not-found": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "not_found" }; case "invalid-config": - return actionability.invalidConfig; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "lifecycle": - return actionability.startStack; + return { ...actionability.startStack, fingerprint_suffix: "lifecycle" }; case "output": - return actionability.provideFlags; - case "runtime": - return actionability.unknown; + return { ...actionability.provideFlags, fingerprint_suffix: "output_format" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index c0450d6659..c4f81b0347 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -90,23 +90,23 @@ const mapTargetError = (error: StackTargetError) => cause: error, }); +const isStateOperation = (operation: string) => + operation === "open" || operation === "discover" || operation === "definition"; + const mapStackError = (error: StackError) => - new StackCommandStatusError({ - reason: - error.operation === "open" || - error.operation === "discover" || - error.operation === "definition" - ? "invalid-config" - : "runtime", - message: error.message, - suggestion: - error.operation === "open" || - error.operation === "discover" || - error.operation === "definition" - ? "Inspect the saved stack state under $SUPABASE_HOME/stacks." - : "Retry the command and use --debug if the stack remains unavailable.", - cause: error, - }); + isStateOperation(error.operation) + ? new StackCommandStatusError({ + reason: "invalid-config", + message: error.message, + suggestion: "Inspect the saved stack state under $SUPABASE_HOME/stacks.", + cause: error, + }) + : new StackCommandStatusError({ + reason: "stack", + message: error.message, + suggestion: "Retry the command and use --debug if the stack remains unavailable.", + cause: error, + }); const serviceReport = ({ instance, observation, error }: ObservedService): ServiceReport => ({ id: instance.id, diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts index 460e5efff3..099aec36c4 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts @@ -1,12 +1,14 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandStopError extends Data.TaggedError("ExperimentalStackStopError")<{ - readonly reason: "flags" | "invalid-config" | "lifecycle" | "unknown"; + readonly reason: "flags" | "invalid-config" | "stack"; readonly message: string; readonly detail?: string; readonly suggestion?: string; @@ -15,13 +17,11 @@ export class StackCommandStopError extends Data.TaggedError("ExperimentalStackSt get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "flags": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "invalid-config": - return actionability.invalidConfig; - case "lifecycle": - return actionability.invalidConfig; - case "unknown": - return actionability.unknown; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index 6dd789617f..1d0fdfff90 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -134,12 +134,13 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: const failures = results.flatMap(({ id, result }) => Result.isFailure(result) ? [{ id, error: result.failure }] : [], ); - if (failures.length > 0) + const [firstFailure] = failures; + if (firstFailure !== undefined) return yield* new StackCommandStopError({ - reason: "unknown", + reason: "stack", message: `Failed to stop ${failures.length} managed stack(s).`, detail: failures.map(({ id, error }) => `${id}: ${error.message}`).join("\n"), - cause: failures, + cause: firstFailure.error, }); const stopped = results.flatMap(({ id, result }) => Result.isSuccess(result) && result.success === "stopped" ? [id] : [], diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 6648036cf2..38d31d761f 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -136,7 +136,7 @@ describe("stack stop", () => { Effect.provide(Layer.provideMerge(f.layer, failedApi)), Effect.flip, ); - expect(error.reason).toBe("unknown"); + expect(error.reason).toBe("stack"); expect(error.detail).toContain(`${f.stack.id}: owner disconnected`); expect(f.output.stdoutText).toBe(""); expect(f.telemetry.flushed).toBe(true); diff --git a/apps/cli/src/commands/functions/serve/serve.errors.ts b/apps/cli/src/commands/functions/serve/serve.errors.ts index 1c6ef725f1..e83eb5776d 100644 --- a/apps/cli/src/commands/functions/serve/serve.errors.ts +++ b/apps/cli/src/commands/functions/serve/serve.errors.ts @@ -1,12 +1,14 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + unclassifiedStackFailureActionability, } from "../../../shared/telemetry/error-actionability.ts"; export class FunctionsServeStackError extends Data.TaggedError("FunctionsServeStackError")<{ - readonly reason: "flags" | "invalid-config" | "lifecycle" | "runtime"; + readonly reason: "flags" | "invalid-config" | "lifecycle" | "runtime" | "stack"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -14,13 +16,20 @@ export class FunctionsServeStackError extends Data.TaggedError("FunctionsServeSt get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { switch (this.reason) { case "flags": - return actionability.provideFlags; + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "invalid-config": - return actionability.invalidConfig; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "lifecycle": - return actionability.startStack; + return ( + causeDeclaration(this.cause) ?? { + ...actionability.startStack, + fingerprint_suffix: "lifecycle", + } + ); case "runtime": - return actionability.unknown; + return { ...actionability.runtimeCrash, fingerprint_suffix: "process_exit" }; + case "stack": + return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; } } } diff --git a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts index 4beab2b035..0ce07b7a0f 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts @@ -1,4 +1,4 @@ -import type { ServiceCreation, Stack } from "@supabase/stack/effect"; +import { StackError, type ServiceCreation, type Stack } from "@supabase/stack/effect"; import { DateTime, Effect, Equal, Fiber, Option, Path, Schema, Stream, type Scope } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -27,16 +27,24 @@ const JwksDocument = Schema.fromJsonString(Schema.Struct({ keys: Schema.Array(Sc const runtimeError = (cause: { readonly message: string }) => cause instanceof FunctionsServeStackError ? cause - : new FunctionsServeStackError({ - reason: - cause instanceof StackFunctionsEnvError || cause instanceof StackConfigError - ? "invalid-config" - : cause instanceof LocalDbRunningError - ? "lifecycle" - : "runtime", - message: cause.message, - cause, - }); + : Schema.is(StackError)(cause) + ? new FunctionsServeStackError({ + reason: "stack", + message: cause.message, + cause, + }) + : new FunctionsServeStackError({ + reason: + cause instanceof StackFunctionsEnvError || + cause instanceof StackConfigError || + Schema.isSchemaError(cause) + ? "invalid-config" + : cause instanceof LocalDbRunningError + ? "lifecycle" + : "runtime", + message: cause.message, + cause, + }); const invalidConfig = (message: string) => new FunctionsServeStackError({ reason: "invalid-config", message }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 552e6bcc25..a31c3a4ee7 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -1,5 +1,6 @@ import { Cause, Option, Predicate } from "effect"; import type { CliError as EffectCliError } from "effect/unstable/cli"; +import type { StackError, StackFailureKind } from "@supabase/stack/effect"; /** * CLI error actionability taxonomy for KPI reporting. @@ -84,43 +85,67 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "auth", "bad_argument", "branch_not_ready", - "conflict", "cancelled", + "confirmation", + "conflict", "connect", "container_configuration", "container_killed", - "daemon_start", "daemon_protocol", + "daemon_start", "daemon_status", + "daemon_stop_timeout", "daemon_transport", - "daemon_upgrade_required", "daemon_upgrade_preflight", + "daemon_upgrade_required", "daemon_upgrade_restart", - "daemon_stop_timeout", "database", + "database_bootstrap", "docker_not_running", + "engine_command", + "engine_timeout", "filesystem", + "flags", "forbidden", "gateway_auth", + "health_check", + "health_timeout", "image_inspect", - "invalid_content", - "invalid_url", "internal_build", "invalid_config", + "invalid_content", + "invalid_url", + "lease_held", + "lifecycle", "network", "not_found", + "operation_timeout", "out_of_memory", + "output_format", + "owner_connection", + "owner_exit", + "owner_startup", + "owner_unavailable", "plan_limit", "platform_error", + "platform_unsupported", "port_allocation", "port_conflict", + "process_exit", + "process_spawn", + "process_stop", "query", "registry_pull", + "release_mismatch", "replication_slots_active", "replication_slots_query", "request_encoding", "request_input", "saml_disabled", + "seed_buckets", + "stack_configuration", + "state_file", + "unclassified", ] as const; type CliErrorFingerprintSuffix = (typeof CLI_ERROR_FINGERPRINT_SUFFIXES)[number]; @@ -448,6 +473,69 @@ export const actionability = { }, } as const satisfies Record; +const stackFailureKindActionability = { + "engine-unavailable": { + ...actionability.dockerNotRunning, + fingerprint_suffix: "docker_not_running", + }, + "engine-timeout": { ...actionability.dockerNotRunning, fingerprint_suffix: "engine_timeout" }, + "engine-command": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command" }, + "image-pull": { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }, + "port-conflict": { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }, + "port-allocation": { ...actionability.runtimeCrash, fingerprint_suffix: "port_allocation" }, + "artifact-download": { + ...actionability.externalNetwork, + fingerprint_suffix: "asset_preparation", + }, + "artifact-integrity": { ...actionability.externalNetwork, fingerprint_suffix: "asset_checksum" }, + "platform-unsupported": { + ...actionability.provideFlags, + fingerprint_suffix: "platform_unsupported", + }, + "process-spawn": { ...actionability.runtimeCrash, fingerprint_suffix: "process_spawn" }, + "process-exit": { ...actionability.runtimeCrash, fingerprint_suffix: "process_exit" }, + "process-stop": { ...actionability.runtimeCrash, fingerprint_suffix: "process_stop" }, + "health-timeout": { ...actionability.runtimeCrash, fingerprint_suffix: "health_timeout" }, + "health-check": { ...actionability.runtimeCrash, fingerprint_suffix: "health_check" }, + "database-bootstrap": { + ...actionability.runtimeCrash, + fingerprint_suffix: "database_bootstrap", + }, + configuration: { ...actionability.invalidConfig, fingerprint_suffix: "stack_configuration" }, + state: { ...actionability.invalidConfig, fingerprint_suffix: "state_file" }, + filesystem: { ...actionability.permission, fingerprint_suffix: "filesystem" }, + "owner-startup": { ...actionability.runtimeCrash, fingerprint_suffix: "owner_startup" }, + "owner-connection": { ...actionability.runtimeCrash, fingerprint_suffix: "owner_connection" }, + "owner-exit": { ...actionability.runtimeCrash, fingerprint_suffix: "owner_exit" }, + "lease-held": { ...actionability.invalidInput, fingerprint_suffix: "lease_held" }, + timeout: { ...actionability.runtimeCrash, fingerprint_suffix: "operation_timeout" }, +} as const satisfies Record; + +const stackErrorReasonActionability = { + "runtime-unavailable": { + ...actionability.dockerNotRunning, + fingerprint_suffix: "docker_not_running", + }, + "owner-unavailable": { ...actionability.startStack, fingerprint_suffix: "owner_unavailable" }, + "release-mismatch": { ...actionability.invalidInput, fingerprint_suffix: "release_mismatch" }, +} as const satisfies Record, CliErrorActionabilityDeclaration>; + +/** Reported when nothing in a stack failure classifies it. */ +export const unclassifiedStackFailureActionability: CliErrorActionabilityDeclaration = { + ...actionability.unknown, + fingerprint_suffix: "unclassified", +}; + +function isTableKey(table: T, value: unknown): value is keyof T { + return typeof value === "string" && Object.hasOwn(table, value); +} + +function stackKindDeclaration(value: unknown): CliErrorActionabilityDeclaration | undefined { + return isTableKey(stackFailureKindActionability, value) + ? stackFailureKindActionability[value] + : undefined; +} + /** * The declaration for a failure confirmed plan-gated by the entitlement * check (`suggestUpgrade`). Shared so every gated surface groups under @@ -844,8 +932,41 @@ const externalActionabilityByTag: Record = { fingerprint_suffix: "request_encoding", }), SchemaError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), + + // @supabase/stack — `reason` is a closed literal and `kind` an open string read against the + // known kinds. The client reason is the more specific fact; a failed outcome's kind stands in + // when the error has no kind. + StackError: (error) => { + const reason = error["reason"]; + if (isTableKey(stackErrorReasonActionability, reason)) { + return stackErrorReasonActionability[reason]; + } + const outcomes = Array.isArray(error["outcomes"]) ? error["outcomes"] : []; + return ( + stackKindDeclaration(error["kind"]) ?? + outcomes + .map((outcome) => + isErrorRecord(outcome) ? stackKindDeclaration(outcome["kind"]) : undefined, + ) + .find((declaration) => declaration !== undefined) ?? + unclassifiedStackFailureActionability + ); + }, }; +/** + * The declaration the classifier gives an error it finds as a wrapper's `cause`: the error's + * own declaration, else its external adapter. + */ +export function causeDeclaration(cause: unknown): CliErrorActionabilityDeclaration | undefined { + const declared = readDeclaration(cause); + if (declared !== undefined) return declared; + const tag = readErrorTag(cause); + if (tag === undefined || !isErrorRecord(cause) || !Object.hasOwn(externalActionabilityByTag, tag)) + return undefined; + return externalActionabilityByTag[tag]?.(cause); +} + function classifyShowHelp(error: ErrorRecord, depth: number): CliErrorActionability | undefined { const errors = error["errors"]; if (!Array.isArray(errors)) return undefined; diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index c9895c01b9..15ac17011a 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -1,4 +1,5 @@ import { Data, Effect, Path, Record } from "effect"; +import type { StackFailureKind } from "./FailureKind.ts"; import { makeArtifactStore } from "./preparation/ArtifactStore.ts"; import { makeSlimServicesSource, @@ -32,6 +33,7 @@ export class ArtifactError extends Data.TaggedError("ArtifactError")<{ readonly version?: string; readonly platform?: string; readonly cause?: unknown; + readonly kind?: StackFailureKind; }> {} /** Lowercase hexadecimal SHA-256 digest. */ @@ -552,7 +554,10 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* readonly version?: string; }) { if (!Object.hasOwn(definitions, request.service)) - return yield* new ArtifactError({ message: `Unknown service kind: ${request.service}` }); + return yield* new ArtifactError({ + message: `Unknown service kind: ${request.service}`, + kind: "configuration", + }); const selected = definitions[request.service]; const version = request.version ?? selected.defaultVersion; const pin = Object.entries(selected.pins).find(([candidate]) => candidate === version)?.[1]; @@ -561,6 +566,7 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* message: `Unsupported ${request.service} artifact version: ${version}`, service: request.service, version, + kind: "configuration", }); return { service: request.service, @@ -623,6 +629,7 @@ export const prepareNativeArtifact = Effect.fn("Artifacts.prepareNativeArtifact" service: request.service, version: resolved.version, platform: platformText(platform), + kind: "platform-unsupported", }); const sourceArtifact = artifactFor(request.service, resolved, target); const key = artifactKey(sourceArtifact); diff --git a/packages/stack/src/FailureKind.ts b/packages/stack/src/FailureKind.ts new file mode 100644 index 0000000000..5ffe49ad3d --- /dev/null +++ b/packages/stack/src/FailureKind.ts @@ -0,0 +1,202 @@ +import { Cause } from "effect"; + +/** + * Closed classification of why a stack operation failed, carried by `StackError.kind` and by + * each failed composition outcome. Values are source-owned identifiers, never error text. + */ +const stackFailureKinds = [ + "engine-unavailable", + "engine-timeout", + "engine-command", + "image-pull", + "port-conflict", + "port-allocation", + "artifact-download", + "artifact-integrity", + "platform-unsupported", + "process-spawn", + "process-exit", + "process-stop", + "health-timeout", + "health-check", + "database-bootstrap", + "configuration", + "state", + "filesystem", + "owner-startup", + "owner-connection", + "owner-exit", + "lease-held", + "timeout", +] as const; + +export type StackFailureKind = (typeof stackFailureKinds)[number]; + +const knownKinds = new Set(stackFailureKinds); + +/** + * Narrows a wire value to a known kind. The wire field is an open string so a client decoding + * an owner from a newer release ignores kinds it does not know instead of rejecting the error. + */ +export const isStackFailureKind = (value: unknown): value is StackFailureKind => + typeof value === "string" && knownKinds.has(value); + +/** Kinds that only describe the mechanism, so an enclosing domain error names the failure better. */ +const generalKinds = new Set(["filesystem", "timeout", "configuration"]); + +const maxDepth = 32; + +const serviceOperationKinds: Readonly> = { + exit: "process-exit", + stop: "process-stop", + remove: "process-stop", + health: "health-check", + bootstrap: "database-bootstrap", + input: "configuration", + config: "configuration", + compose: "configuration", +}; + +const hostReasonKinds: Readonly> = { + "connection-failure": "owner-connection", + "owner-starting": "owner-startup", + sweeping: "lease-held", + "runtime-unavailable": "engine-unavailable", + "invalid-owner-pid": "owner-exit", + "owner-exit-pending": "owner-exit", + "owner-exit-zombie": "owner-exit", + "owner-exit-probe": "owner-exit", +}; + +const hostOperationKinds: Readonly> = { + startup: "owner-startup", + connect: "owner-connection", + shutdown: "owner-connection", + "shutdown-exit": "owner-exit", +}; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null; + +const lookup = (table: Readonly>, key: unknown) => + typeof key === "string" && Object.hasOwn(table, key) ? table[key] : undefined; + +/** `scope` is the nearest enclosing service operation, or `engine` inside a container command. */ +const classify = ( + value: unknown, + scope: string | undefined, + seen: Set, + depth: number, +): StackFailureKind | undefined => { + if (!isRecord(value) || seen.has(value) || depth > maxDepth) return undefined; + seen.add(value); + const visit = (next: unknown, nextScope = scope) => classify(next, nextScope, seen, depth + 1); + if (Cause.isCause(value)) { + for (const reason of value.reasons) { + const kind = Cause.isFailReason(reason) + ? visit(reason.error) + : Cause.isDieReason(reason) + ? visit(reason.defect) + : undefined; + if (kind !== undefined) return kind; + } + return undefined; + } + if (Cause.isTimeoutError(value)) + return scope === "health" + ? "health-timeout" + : scope === "engine" + ? "engine-timeout" + : "timeout"; + if (isStackFailureKind(value.kind)) return value.kind; + const nested = () => visit(value.cause); + const within = (own: StackFailureKind | undefined) => { + const inner = nested(); + return inner !== undefined && !generalKinds.has(inner) ? inner : (own ?? inner); + }; + switch (value._tag) { + case "StackError": + return value.reason === "runtime-unavailable" ? "engine-unavailable" : undefined; + case "ContainerError": { + const inner = visit(value.cause, "engine"); + if (value.operation === "pull" && inner !== "engine-unavailable") return "image-pull"; + return inner ?? "engine-command"; + } + case "ContainerLaunchError": + case "ServiceLaunchError": + return visit(value.failure); + case "NativePortCollision": + case "PortError": + return "port-allocation"; + case "NativeProcessError": + return scope === "launch" + ? "process-spawn" + : scope === "stop" || scope === "remove" + ? "process-stop" + : undefined; + case "ServiceError": + return ( + visit(value.cause, typeof value.operation === "string" ? value.operation : scope) ?? + lookup(serviceOperationKinds, value.operation) + ); + case "ArtifactError": + case "PreparationError": { + // A local cache write failing is not a download failure. + const inner = nested(); + return inner === undefined || inner === "timeout" || inner === "configuration" + ? "artifact-download" + : inner; + } + case "ArtifactIntegrityError": + return "artifact-integrity"; + case "DatabaseBootstrapError": + return within("database-bootstrap"); + case "CatalogError": + return within( + value.operation === "config" || value.cause === undefined ? "configuration" : undefined, + ); + case "SupabaseCompositionError": + return within(value.cause === undefined ? "configuration" : undefined); + case "InvalidStackIdentityError": + case "InvalidProjectRootError": + case "SchemaError": + return "configuration"; + case "StateError": + return within("state"); + case "StackHostError": + return value.reason === "lease-held" + ? "lease-held" + : value.reason === "runtime-unavailable" + ? "engine-unavailable" + : nested(); + case "HostProcessError": + return ( + lookup(hostReasonKinds, value.reason) ?? within(lookup(hostOperationKinds, value.operation)) + ); + case "RpcClientError": + return "owner-connection"; + case "OrchestratorError": { + const inner = nested(); + if (inner !== undefined || !Array.isArray(value.outcomes)) return inner; + for (const outcome of value.outcomes) { + const kind = + isRecord(outcome) && isRecord(outcome.result) ? visit(outcome.result.cause) : undefined; + if (kind !== undefined) return kind; + } + return undefined; + } + case "PlatformError": + return isRecord(value.reason) && value.reason.module === "FileSystem" + ? "filesystem" + : undefined; + default: + return nested(); + } +}; + +/** + * Classifies a failure, a `Cause`, or a defect by walking its typed error chain. The most specific + * typed signal wins; error text is never read. + */ +export const failureKind = (cause: unknown): StackFailureKind | undefined => + classify(cause, undefined, new Set(), 0); diff --git a/packages/stack/src/FailureKind.unit.test.ts b/packages/stack/src/FailureKind.unit.test.ts new file mode 100644 index 0000000000..1b8817ac82 --- /dev/null +++ b/packages/stack/src/FailureKind.unit.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, PlatformError } from "effect"; +import { RpcClientDefect, RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { ArtifactError } from "./Artifacts.ts"; +import { failureKind, type StackFailureKind } from "./FailureKind.ts"; +import { HostProcessError } from "./HostProcess.ts"; +import { OrchestratorError } from "./Orchestrator.ts"; +import { PortError } from "./Ports.ts"; +import { StackError } from "./Rpc.ts"; +import { ContainerError } from "./runtime/Container.ts"; +import { NativeProcessError } from "./runtime/NativeProcess.ts"; +import { ArtifactIntegrityError, PreparationError } from "./preparation/Errors.ts"; +import { ServiceError, ServiceLaunchError, type RuntimeSession } from "./Service.ts"; +import { StackHostError } from "./StackHost.ts"; +import { StateError } from "./State.ts"; +import { SupabaseCompositionError } from "./composition/Supabase.ts"; + +const fileFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFile", + pathOrDescriptor: "/tmp/stack", +}); +const runtime: RuntimeSession = { + health: Effect.void, + exit: Effect.succeed(Exit.void), + stop: Effect.void, + remove: Effect.void, +}; +const cyclic: { cause?: unknown } = {}; +cyclic.cause = cyclic; + +const cases: ReadonlyArray = [ + [ + "a failed pull as image-pull", + new ServiceError({ + operation: "prepare", + message: "pull access denied", + cause: new ContainerError({ operation: "pull", message: "pull access denied" }), + }), + "image-pull", + ], + [ + "a pull against an unreachable engine as engine-unavailable", + new ContainerError({ + operation: "pull", + message: "Cannot connect to the Docker daemon", + kind: "engine-unavailable", + }), + "engine-unavailable", + ], + [ + "a wrapped container start that lost its engine-chosen host port as port-allocation", + new ContainerError({ + operation: "create", + message: "port is already allocated", + cause: new ContainerError({ + operation: "start", + message: "port is already allocated", + kind: "port-allocation", + }), + }), + "port-allocation", + ], + [ + "an engine command timeout as engine-timeout", + new ContainerError({ operation: "ps", message: "timed out", cause: new Cause.TimeoutError() }), + "engine-timeout", + ], + [ + "any other engine failure as engine-command", + new ContainerError({ operation: "rm", message: "removal failed" }), + "engine-command", + ], + [ + "a readiness timeout as health-timeout", + new ServiceError({ + operation: "health", + message: "Service health timed out", + cause: new Cause.TimeoutError(), + }), + "health-timeout", + ], + [ + "a launch timeout as timeout", + new ServiceError({ + operation: "launch", + message: "timed out", + cause: new Cause.TimeoutError(), + }), + "timeout", + ], + [ + "a readiness failure without a typed cause as health-check", + new ServiceError({ operation: "health", message: "HTTP 503" }), + "health-check", + ], + [ + "a native port reservation failure as port-allocation", + new ServiceError({ + operation: "launch", + message: "No native service port is available", + cause: new PortError({ key: "db", message: "No native service port is available" }), + }), + "port-allocation", + ], + [ + "a taken port the caller requested by number as port-conflict", + new ServiceError({ + operation: "launch", + message: "Port 54321 is already in use", + cause: new PortError({ key: "db", message: "in use", kind: "port-conflict" }), + }), + "port-conflict", + ], + [ + "a native launch failure as process-spawn", + new ServiceError({ + operation: "launch", + message: "ENOENT", + cause: new NativeProcessError({ message: "ENOENT" }), + }), + "process-spawn", + ], + [ + "a native stop failure as process-stop", + new ServiceError({ + operation: "stop", + message: "kill failed", + cause: new NativeProcessError({ message: "kill failed" }), + }), + "process-stop", + ], + [ + "a launch failure that kept its runtime by its failure", + new ServiceLaunchError({ + failure: new ServiceError({ operation: "exit", message: "Process exited with 1" }), + runtime, + }), + "process-exit", + ], + [ + "a checksum mismatch below artifact preparation as artifact-integrity", + new ServiceError({ + operation: "prepare", + message: "Unable to prepare auth artifact", + cause: new ArtifactError({ + message: "Unable to prepare auth artifact", + cause: new PreparationError({ + message: "download failed", + cause: new ArtifactIntegrityError({ message: "checksum mismatch" }), + }), + }), + }), + "artifact-integrity", + ], + [ + "a local cache write failure inside artifact preparation as filesystem", + new ArtifactError({ message: "Unable to prepare auth artifact", cause: fileFailure }), + "filesystem", + ], + [ + "a file failure with no enclosing domain as filesystem", + new ServiceError({ operation: "launch", message: "denied", cause: fileFailure }), + "filesystem", + ], + [ + "a state file failure as state", + new StateError({ operation: "read", message: "denied", cause: fileFailure }), + "state", + ], + [ + "an operational failure inside composition as its own kind", + new SupabaseCompositionError({ + message: "pull access denied", + cause: new ContainerError({ operation: "pull", message: "pull access denied" }), + }), + "image-pull", + ], + [ + "an invalid composition as configuration", + new SupabaseCompositionError({ message: "Duplicate service kind auth" }), + "configuration", + ], + [ + "an owner that kept running after shutdown as owner-exit", + new HostProcessError({ + operation: "shutdown-exit", + message: "still running", + reason: "owner-exit-pending", + }), + "owner-exit", + ], + [ + "an unreachable owner control endpoint as owner-connection", + new HostProcessError({ + operation: "connect", + message: "Timed out connecting", + reason: "connection-failure", + }), + "owner-connection", + ], + [ + "an owner startup failure the owner classified by that kind", + new HostProcessError({ operation: "startup", message: "failed", kind: "engine-unavailable" }), + "engine-unavailable", + ], + [ + "an unclassified owner startup failure as owner-startup", + new HostProcessError({ operation: "startup", message: "Owner exited before reporting" }), + "owner-startup", + ], + [ + "a held stack lease as lease-held", + new StackHostError({ operation: "lease", message: "held", reason: "lease-held" }), + "lease-held", + ], + [ + "an RPC transport failure as owner-connection", + new RpcClientError({ reason: new RpcClientDefect({ message: "closed", cause: undefined }) }), + "owner-connection", + ], + [ + "a stack error by the kind it carries", + new StackError({ operation: "start", message: "failed", kind: "image-pull" }), + "image-pull", + ], + [ + "a stack error with an unknown wire kind by its reason", + new StackError({ + operation: "start", + message: "failed", + kind: "from-a-newer-release", + reason: "runtime-unavailable", + }), + "engine-unavailable", + ], + [ + "a composition failure by its first classified outcome", + new OrchestratorError({ + operation: "start", + message: "Composition start had failures", + outcomes: [ + { + id: "rest", + result: Exit.fail(new OrchestratorError({ operation: "start", message: "Blocked" })), + }, + { id: "db", result: Exit.fail(new ServiceError({ operation: "exit", message: "exit 1" })) }, + ], + }), + "process-exit", + ], + [ + "a defect in a cause", + Cause.die(new PortError({ key: "api", message: "in use" })), + "port-allocation", + ], + [ + "a cyclic cause chain by the enclosing operation", + new ServiceError({ operation: "exit", message: "exit 1", cause: cyclic }), + "process-exit", + ], +]; + +describe("failureKind", () => { + it.each(cases)("classifies %s", (_, failure, expected) => { + expect(failureKind(failure)).toBe(expected); + }); +}); diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index 50aea1b49b..0a54727ebb 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -27,6 +27,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { closeSync, fstatSync, openSync, readSync } from "node:fs"; import { fileURLToPath } from "node:url"; import packageJson from "../package.json" with { type: "json" }; +import { isStackFailureKind, type StackFailureKind } from "./FailureKind.ts"; import { HOST_PROCESS_DISPATCH_SENTINEL, isBunVirtualPath } from "./internal/dispatch-markers.ts"; import { failureMessage } from "./internal/failure-message.ts"; import { stackSourceDigest } from "./internal/release.ts"; @@ -82,6 +83,7 @@ export class HostProcessError extends Data.TaggedError("HostProcessError")<{ readonly message: string; readonly cause?: unknown; readonly reason?: HostFailureReason; + readonly kind?: StackFailureKind; }> {} type HostFailureReason = | "unregistered" @@ -126,18 +128,28 @@ export const ShutdownFailure = Schema.Struct({ id: Schema.String, succeeded: Schema.Boolean, error: Schema.optionalKey(Schema.String), + /** A `StackFailureKind`, kept an open string so an older release ignores newer kinds. */ + kind: Schema.optionalKey(Schema.String), }), ), ), + /** A `StackFailureKind`, kept an open string so an older release ignores newer kinds. */ + kind: Schema.optionalKey(Schema.String), }); export interface ShutdownFailure extends Schema.Schema.Type {} -const error = (operation: string, cause: unknown, reason?: HostFailureReason) => +const error = ( + operation: string, + cause: unknown, + reason?: HostFailureReason, + kind?: StackFailureKind, +) => new HostProcessError({ operation, message: failureMessage(cause), cause, ...(reason === undefined ? {} : { reason }), + ...(kind === undefined ? {} : { kind }), }); /** Matches owner failures by reason. */ export const hasReason = @@ -294,6 +306,8 @@ export const connectHost = Effect.fn("HostProcess.connectHost")(function* ( ? error( "connect", `Stack owner did not become reachable: ${failure.message} (owner log: ${state.ownerLog(stackId)})`, + undefined, + "owner-startup", ) : failure, ), @@ -353,6 +367,7 @@ const readyLine = Schema.Union([ type: Schema.Literal("error"), message: Schema.String, reason: Schema.optionalKey(Schema.Literals(["lease-held", "exists", "runtime-unavailable"])), + kind: Schema.optionalKey(Schema.String), }), ]); @@ -445,12 +460,13 @@ const spawnOwner = Effect.fn("HostProcess.spawnOwner")(function* ( }), (descriptor) => Effect.gen(function* () { - const failure = (cause: unknown, reason?: HostFailureReason) => { + const failure = (cause: unknown, reason?: HostFailureReason, kind?: StackFailureKind) => { const tail = logTail(descriptor); return error( "startup", `Stack owner failed to start: ${error("startup", cause).message} (owner log: ${log})${tail.length === 0 ? "" : `\n${tail}`}`, reason, + kind, ); }; const spawnFailed = yield* Deferred.make(); @@ -526,8 +542,12 @@ const spawnOwner = Effect.fn("HostProcess.spawnOwner")(function* ( ); } return yield* line.reason === "lease-held" || line.reason === "exists" - ? error("startup", "Stack already exists; use open") - : failure(line.message, line.reason); + ? error("startup", "Stack already exists; use open", undefined, "lease-held") + : failure( + line.message, + line.reason, + isStackFailureKind(line.kind) ? line.kind : undefined, + ); } if (line.endpoint.stackId !== options.stackId) return yield* failure( diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index c7f4df0938..14749efa38 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -1,6 +1,7 @@ import { NodeServices, NodeSocketServer } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; import { Cause, Context, Effect, Exit, FileSystem, Layer, Option, Ref, Scope } from "effect"; +import { failureKind } from "./FailureKind.ts"; import { makePorts, portBase, portSpan, PortError, reserveNativePort } from "./Ports.ts"; import * as State from "./State.ts"; @@ -76,6 +77,7 @@ it.live("reports an occupied saved port without moving its assignment", () => expect(failure).toBeInstanceOf(PortError); expect(failure.message).toContain(`api at 127.0.0.1:${first.port}`); expect(failure.message).not.toContain("claims this port"); + expect(failureKind(failure)).toBe("port-allocation"); expect((yield* state.read("stack"))?.ports[0]?.port).toBe(first.port); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -284,6 +286,7 @@ it.live("names the stack claiming an explicit port that a live listener holds", expect(failure).toBeInstanceOf(PortError); expect(failure.message).toContain(`db/sql at 127.0.0.1:${held.port}`); expect(failure.message).toContain(`stack "holder" on branch-holder in ${root}`); + expect(failureKind(failure)).toBe("port-conflict"); expect((yield* state.read("current"))?.ports).toEqual([]); }), ).pipe(Effect.provide(NodeServices.layer)), diff --git a/packages/stack/src/Ports.ts b/packages/stack/src/Ports.ts index 8b55842335..b06dd5c957 100644 --- a/packages/stack/src/Ports.ts +++ b/packages/stack/src/Ports.ts @@ -1,5 +1,6 @@ import { Cause, Crypto, Data, Effect, Exit, Hash, Option, Scope } from "effect"; import * as Net from "node:net"; +import type { StackFailureKind } from "./FailureKind.ts"; import type * as State from "./State.ts"; export const portBase = 20000; @@ -8,12 +9,18 @@ export const portSpan = 12768; /** Co-prime with the span, so the scan visits every port once and steps past reserved ranges. */ const portStride = 257; +/** Without a `kind`, the failure is the stack's own allocation (`port-allocation`). */ export class PortError extends Data.TaggedError("PortError")<{ readonly key: string; readonly message: string; readonly cause?: unknown; + readonly kind?: StackFailureKind; }> {} +/** Only a port the caller requested by number is the user's to change. */ +const takenKind = (request: PortRequest): StackFailureKind => + request.port === "auto" ? "port-allocation" : "port-conflict"; + export interface PortRequest { readonly stackId: string; readonly key: string; @@ -72,17 +79,21 @@ const resolveRequest = ( new PortError({ key: request.key, message: "The requested listener differs from its saved assignment", + kind: "configuration", }), ); const requested = saved?.port ?? request.port; if (requested === "auto") return Effect.succeed({ saved, requested }); if (!Number.isInteger(requested) || requested < 1 || requested > 65535) - return Effect.fail(new PortError({ key: request.key, message: "Invalid public port" })); + return Effect.fail( + new PortError({ key: request.key, message: "Invalid public port", kind: "configuration" }), + ); if (stack.ports.some((claim) => claim.key !== request.key && claim.port === requested)) return Effect.fail( new PortError({ key: request.key, message: `Public port ${requested} is claimed by another listener of this stack`, + ...(request.port === "auto" ? {} : { kind: "configuration" as const }), }), ); return Effect.succeed({ saved, requested }); @@ -236,6 +247,7 @@ export const makePorts = (state: State.Interface, platform: NodeJS.Platform = pr return yield* new PortError({ key: request.key, message: `Public port ${requested} for ${request.key} at ${request.host}:${requested} is already in use${yield* claimedBy(yield* state.claims, request.stackId, requested)}`, + kind: takenKind(request), }); }); @@ -248,7 +260,11 @@ export const makePorts = (state: State.Interface, platform: NodeJS.Platform = pr Effect.gen(function* () { const stack = yield* state.read(request.stackId); if (stack === undefined) - return yield* new PortError({ key: request.key, message: "Stack is not registered" }); + return yield* new PortError({ + key: request.key, + message: "Stack is not registered", + kind: "state", + }); const { saved, requested } = yield* resolveRequest(stack, request); const others = yield* state.claims; const claimed = new Set([ @@ -323,6 +339,7 @@ export const makePorts = (state: State.Interface, platform: NodeJS.Platform = pr key: request.key, message: `${error.value.message}${yield* claimedBy(others, request.stackId, requested)}`, cause: error.value.cause, + kind: takenKind(request), }); failures++; lastFailure = error.value; diff --git a/packages/stack/src/PromiseClient.ts b/packages/stack/src/PromiseClient.ts index 245c83f748..98fd3662c7 100644 --- a/packages/stack/src/PromiseClient.ts +++ b/packages/stack/src/PromiseClient.ts @@ -292,7 +292,9 @@ const makeAdapter = (client: Client) => { const creationJson = Schema.toCodecJson(CreationSchema); const decodeCreation = (operation: string, creation: unknown) => Schema.decodeUnknownEffect(creationJson)(creation).pipe( - Effect.mapError((cause) => new StackError({ operation, message: cause.message })), + Effect.mapError( + (cause) => new StackError({ operation, message: cause.message, kind: "configuration" }), + ), ); const decodeCreations = (operation: string, creations: ReadonlyArray) => Effect.forEach(creations, (creation) => decodeCreation(operation, creation)); @@ -331,6 +333,7 @@ export const stackAdapter = (client: Client) => { new StackError({ operation: "restart", message: "Service kind cannot change", + kind: "configuration", }), ), ), diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 873f1b7b70..40e1233b1d 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -5,6 +5,7 @@ import { snapshotScopes } from "./services/DatabaseSnapshot.ts"; import { causeMessage, CompositionConfig, OrchestratorError } from "./Orchestrator.ts"; import { CommandInvocation } from "./Commands.ts"; import { StackKeysInput } from "./State.ts"; +import { failureKind } from "./FailureKind.ts"; import { failureMessage } from "./internal/failure-message.ts"; import { LogPosition, LogRecord } from "./host/LogRecord.ts"; @@ -12,6 +13,8 @@ const Outcome = Schema.Struct({ id: Schema.String, succeeded: Schema.Boolean, error: Schema.optionalKey(Schema.String), + /** A `StackFailureKind`, kept an open string so an older release ignores newer kinds. */ + kind: Schema.optionalKey(Schema.String), }); /** A typed failure returned by the stack owner. */ @@ -27,8 +30,15 @@ export class StackError extends Schema.TaggedError()("StackError", { reason: Schema.optionalKey( Schema.Literals(["owner-unavailable", "release-mismatch", "runtime-unavailable"]), ), + /** A `StackFailureKind`, kept an open string so an older release ignores newer kinds. */ + kind: Schema.optionalKey(Schema.String), }) {} +const kindOf = (cause: unknown) => { + const kind = failureKind(cause); + return kind === undefined ? {} : { kind }; +}; + /** Maps an owner failure to the RPC error, preserving per-member composition outcomes. */ export const stackError = (operation: string, cause: unknown): StackError => { if (Schema.is(StackError)(cause)) return cause; @@ -39,10 +49,13 @@ export const stackError = (operation: string, cause: unknown): StackError => { outcomes: cause.outcomes.map(({ id, result }) => ({ id, succeeded: Exit.isSuccess(result), - ...(Exit.isFailure(result) ? { error: causeMessage(result.cause) } : {}), + ...(Exit.isFailure(result) + ? { error: causeMessage(result.cause), ...kindOf(result.cause) } + : {}), })), + ...kindOf(cause), }); - return new StackError({ operation, message: failureMessage(cause) }); + return new StackError({ operation, message: failureMessage(cause), ...kindOf(cause) }); }; const ServiceErrorSchema = Schema.TaggedStruct("ServiceError", { diff --git a/packages/stack/src/Rpc.unit.test.ts b/packages/stack/src/Rpc.unit.test.ts index 7cf057996b..a01f7c0ef0 100644 --- a/packages/stack/src/Rpc.unit.test.ts +++ b/packages/stack/src/Rpc.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Schema } from "effect"; +import { Cause, Effect, Exit, Schema } from "effect"; import { OrchestratorError } from "./Orchestrator.ts"; import { StackError, stackError } from "./Rpc.ts"; import { ServiceError } from "./Service.ts"; @@ -43,4 +43,43 @@ describe("stackError", () => { expect(decoded.outcomes).toEqual([]); }), ); + + it.effect("classifies the failure and each failed composition outcome", () => + Effect.gen(function* () { + const failure = stackError( + "startComposition", + new OrchestratorError({ + operation: "start", + message: "Composition start had failures", + outcomes: [ + { + id: "rest", + result: Exit.fail( + new OrchestratorError({ operation: "start", message: "Blocked by prerequisites" }), + ), + }, + { + id: "db", + result: Exit.fail( + new ServiceError({ + operation: "health", + message: "Service health timed out", + cause: new Cause.TimeoutError(), + }), + ), + }, + { id: "mail", result: Exit.void }, + ], + }), + ); + + const decoded = yield* roundTrip(failure); + expect(decoded.kind).toBe("health-timeout"); + expect(decoded.outcomes).toEqual([ + { id: "rest", succeeded: false, error: "Blocked by prerequisites" }, + { id: "db", succeeded: false, error: expect.any(String), kind: "health-timeout" }, + { id: "mail", succeeded: true }, + ]); + }), + ); }); diff --git a/packages/stack/src/Service.ts b/packages/stack/src/Service.ts index 17e0e0883a..d68d6eba80 100644 --- a/packages/stack/src/Service.ts +++ b/packages/stack/src/Service.ts @@ -12,6 +12,7 @@ import { SubscriptionRef, } from "effect"; import type { Stream } from "effect"; +import type { StackFailureKind } from "./FailureKind.ts"; type ServiceLifecycle = "stopped" | "starting" | "running" | "stopping"; type ServiceHealth = "starting" | "healthy" | "unhealthy"; @@ -37,6 +38,8 @@ export class ServiceError extends Data.TaggedError("ServiceError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; + /** Set where the failure site knows a cause its message alone describes. */ + readonly kind?: StackFailureKind; }> {} export class ServiceDestroyed extends Data.TaggedError("ServiceDestroyed")<{ diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 0bb65d4a65..4034ec1212 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -30,6 +30,7 @@ import { } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; import { OrchestratorError } from "./Orchestrator.ts"; +import { ContainerError } from "./runtime/Container.ts"; import { CommandEvent, StackError } from "./Rpc.ts"; import * as State from "./State.ts"; import { bindControl, makeRuntime } from "./StackHost.ts"; @@ -259,9 +260,15 @@ it.live("preserves composition outcomes over RPC", () => expect("outcomes" in error).toBe(true); if (!("outcomes" in error)) return yield* Effect.die("Missing composition outcomes"); + expect(error.kind).toBe("port-conflict"); expect(error.outcomes).toEqual([ { id: lazy.id, succeeded: true }, - { id: blocked.id, succeeded: false, error: expect.stringContaining(String(port)) }, + { + id: blocked.id, + succeeded: false, + error: expect.stringContaining(String(port)), + kind: "port-conflict", + }, ]); yield* shutdownOwner(runtime.access, true); }), @@ -335,6 +342,7 @@ it.live("reports destroy and fallback stop failures together", () => message: string, id: string, reason: string, + cause: unknown, ) => new OrchestratorError({ operation, @@ -342,7 +350,7 @@ it.live("reports destroy and fallback stop failures together", () => outcomes: [ { id, - result: Exit.fail(new OrchestratorError({ operation, message: reason })), + result: Exit.fail(new OrchestratorError({ operation, message: reason, cause })), }, ], }); @@ -356,6 +364,7 @@ it.live("reports destroy and fallback stop failures together", () => "Namespace destroy had failures", "database-destroy", "data removal refused", + new State.StateError({ operation: "remove", message: "data removal refused" }), ), ), stop: Effect.fail( @@ -364,6 +373,7 @@ it.live("reports destroy and fallback stop failures together", () => "Composition stop had failures", "database-stop", "process stop refused", + new ContainerError({ operation: "stop", message: "process stop refused" }), ), ), }, @@ -376,6 +386,7 @@ it.live("reports destroy and fallback stop failures together", () => expect(failure.message).toContain("fallback stop failed: Composition stop had failures"); expect(failure.message).toContain("database-stop:"); expect(failure.message).toContain("process stop refused"); + expect(failure.kind).toBe("state"); expect("outcomes" in failure).toBe(true); if (!("outcomes" in failure)) return yield* Effect.die("shutdown outcomes were missing"); expect(failure.outcomes).toEqual( @@ -384,11 +395,13 @@ it.live("reports destroy and fallback stop failures together", () => id: "database-destroy", succeeded: false, error: expect.stringContaining("data removal refused"), + kind: "state", }, { id: "database-stop", succeeded: false, error: expect.stringContaining("process stop refused"), + kind: "engine-command", }, ]), ); diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index 23f2a5b813..4696f45d35 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -38,6 +38,7 @@ import { type HostEndpoint, type ShutdownFailure, } from "./HostProcess.ts"; +import { failureKind } from "./FailureKind.ts"; import { projectSegmentFor } from "./identity/Identity.ts"; import * as Owner from "./Owner.ts"; import { StackError, stackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; @@ -99,6 +100,7 @@ const shutdownFailure = (cause: unknown): ShutdownFailure => { return { message: failure.message, ...(failure.outcomes === undefined ? {} : { outcomes: failure.outcomes }), + ...(failure.kind === undefined ? {} : { kind: failure.kind }), }; }; @@ -270,11 +272,14 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( if (Exit.isFailure(stopExit)) { const describeCause = (cause: Cause.Cause) => { const error = Option.match(Cause.findErrorOption(cause), { - onNone: () => - new StackError({ + onNone: () => { + const kind = failureKind(cause); + return new StackError({ operation: "shutdown", message: Cause.pretty(cause), - }), + ...(kind === undefined ? {} : { kind }), + }); + }, onSome: (value) => stackError("shutdown", value), }); const failedOutcomes = error.outcomes @@ -295,10 +300,12 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( ...(destroyFailure.error.outcomes ?? []), ...(stopFailure.error.outcomes ?? []), ]; + const kind = destroyFailure.error.kind ?? stopFailure.error.kind; return yield* new StackError({ operation: "shutdown", message: `${destroyFailure.message}; fallback stop failed: ${stopFailure.message}`, ...(outcomes.length === 0 ? {} : { outcomes }), + ...(kind === undefined ? {} : { kind }), }); } retiringAfterDestroyFailure = true; diff --git a/packages/stack/src/destroy.runtime-unavailable.integration.test.ts b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts index 595b75a6a5..5498e15705 100644 --- a/packages/stack/src/destroy.runtime-unavailable.integration.test.ts +++ b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts @@ -245,6 +245,7 @@ it.live.skipIf(process.getuid?.() === 0)( const destroyFailure = yield* Effect.flip(stack.destroy); expect(destroyFailure.message).toContain("start Docker and run destroy again"); + expect(destroyFailure.kind).toBe("engine-unavailable"); expect(yield* discover({ stateRoot })).toHaveLength(1); expect(yield* fs.exists(`${instanceRoot}/owned-file`)).toBe(true); }).pipe(Effect.scoped, Effect.provide(layer)), @@ -269,6 +270,7 @@ it.live("keeps a stack registered when its container engine rejects the listing" const destroyFailure = yield* Effect.flip(stack.destroy); expect(destroyFailure.message).toContain("permission denied"); + expect(destroyFailure.kind).toBe("engine-command"); expect(yield* discover({ stateRoot: options.stateRoot })).toHaveLength(1); }).pipe(Effect.scoped, Effect.provide(layer)), @@ -305,6 +307,7 @@ it.live("keeps a stack registered when its container engine is reachable but cle const destroyFailure = yield* Effect.flip(stack.destroy); expect(destroyFailure.message).toContain("container removal failed"); + expect(destroyFailure.kind).toBe("engine-command"); expect(yield* discover({ stateRoot: options.stateRoot })).toHaveLength(1); }).pipe(Effect.scoped, Effect.provide(layer)), diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 7d561e8480..bd8759c44b 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -43,6 +43,7 @@ import { import { listStackContainers, removeStackContainersCommand } from "./runtime/Container.ts"; import { volumeDataCleanupCommands } from "./storage/DockerDatabaseStorage.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; +import { failureKind, isStackFailureKind, type StackFailureKind } from "./FailureKind.ts"; import { failureMessage } from "./internal/failure-message.ts"; import * as State from "./State.ts"; import type { SavedStack, StackCredentials, StackKeysInput } from "./State.ts"; @@ -68,6 +69,7 @@ export { initialization, postgres } from "./Commands.ts"; export { resolveNativePostgresUser } from "./runtime/postgres-user.ts"; export { apiRoute } from "./host/Endpoints.ts"; export { StackError } from "./Rpc.ts"; +export type { StackFailureKind } from "./FailureKind.ts"; export type { ServiceCreation } from "./services/Catalog.ts"; /** A service creation as `services.create` accepts it, before stack credentials fill its inputs. */ export type ServiceCreationInput = CatalogServiceCreationInput; @@ -123,22 +125,29 @@ const ownerAbsent = hasReason("not-running", "sweeping"); /** The stack is no longer registered: it was destroyed or swept. */ const stackGone = hasReason("unregistered"); -const failure = (operation: string, cause: unknown): StackError => - Schema.is(StackError)(cause) - ? cause - : new StackError({ - operation, - message: Schema.is(RpcClientError)(cause) - ? `Owner response unavailable; the request outcome is uncertain: ${cause.message}` - : failureMessage(cause), - ...(ownerAbsent(cause) || stackGone(cause) - ? { reason: "owner-unavailable" as const } - : hasReason("release-mismatch")(cause) - ? { reason: "release-mismatch" as const } - : hasReason("runtime-unavailable")(cause) - ? { reason: "runtime-unavailable" as const } - : {}), - }); +/** Drops an outcome kind this release does not know, so it never re-emits one. */ +const knownOutcomeKind = ({ kind, ...outcome }: O) => + isStackFailureKind(kind) ? { ...outcome, kind } : outcome; + +/** An explicit `kind` wins; otherwise the cause's typed chain is classified. */ +const failure = (operation: string, cause: unknown, kind?: StackFailureKind): StackError => { + if (Schema.is(StackError)(cause)) return cause; + const classified = kind ?? failureKind(cause); + return new StackError({ + operation, + message: Schema.is(RpcClientError)(cause) + ? `Owner response unavailable; the request outcome is uncertain: ${cause.message}` + : failureMessage(cause), + ...(ownerAbsent(cause) || stackGone(cause) + ? { reason: "owner-unavailable" as const } + : hasReason("release-mismatch")(cause) + ? { reason: "release-mismatch" as const } + : hasReason("runtime-unavailable")(cause) + ? { reason: "runtime-unavailable" as const } + : {}), + ...(classified === undefined ? {} : { kind: classified }), + }); +}; type Kind = ServiceCreation["service"]; type ServiceCreationRestartInput = @@ -357,6 +366,7 @@ const destroyWithoutEngine = Effect.fn("Stack.destroyWithoutEngine")(function* ( return yield* failure( "destroy", "An owner for this stack started during destroy; run destroy again", + "lease-held", ); yield* Effect.addFinalizer(() => state.retractHolder(id).pipe(Effect.ignore)); yield* state.publishHolder(id, { @@ -376,6 +386,7 @@ const destroyWithoutEngine = Effect.fn("Stack.destroyWithoutEngine")(function* ( return yield* failure( "destroy", `Stack data at ${blocked} can only be removed by ${engineName}; start ${engineName} and run destroy again`, + "engine-unavailable", ); } // Containers are labelled with the resolved data root the owner ran with. @@ -587,7 +598,10 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( new StackError({ operation: "shutdown", message: rejected.message, - ...(rejected.outcomes === undefined ? {} : { outcomes: rejected.outcomes }), + ...(rejected.outcomes === undefined + ? {} + : { outcomes: rejected.outcomes.map(knownOutcomeKind) }), + ...(isStackFailureKind(rejected.kind) ? { kind: rejected.kind } : {}), }), ), }), @@ -857,7 +871,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( } const savedDefinition = Effect.gen(function* () { const current = yield* state.read(saved.id); - if (current === undefined) return yield* failure("definition", "Stack does not exist"); + if (current === undefined) return yield* failure("definition", "Stack does not exist", "state"); return current; }).pipe(Effect.mapError((cause) => failure("definition", cause))); const definitions = savedDefinition.pipe(Effect.map((current) => current.instances)); @@ -950,7 +964,7 @@ export const create = Effect.fn("Stack.create")( const session = saved.lifetime === "session"; if (session || options.startOwner === true) { if ((yield* state.read(id)) !== undefined) - return yield* failure("create", "Stack already exists; use open"); + return yield* failure("create", "Stack already exists; use open", "lease-held"); // The owner registers the stack under its lease and removes it if its startup fails, so no // sweep sees a session stack unowned and a failed launch leaves no registration behind. const access = yield* launchHost(state, { @@ -964,7 +978,7 @@ export const create = Effect.fn("Stack.create")( yield* state.withLock( Effect.gen(function* () { if ((yield* state.read(id)) !== undefined) - return yield* failure("create", "Stack already exists; use open"); + return yield* failure("create", "Stack already exists; use open", "lease-held"); yield* state.save(saved); }), ); @@ -978,7 +992,7 @@ export const open = Effect.fn("Stack.open")( function* (options: OpenOptions) { const state = yield* stateFor(options.stateRoot); const saved = yield* state.read(options.id); - if (saved === undefined) return yield* failure("open", "Stack does not exist"); + if (saved === undefined) return yield* failure("open", "Stack does not exist", "state"); const locations = { stateRoot: options.stateRoot, cacheRoot: options.cacheRoot }; const access = !options.startOwner ? undefined @@ -1093,28 +1107,38 @@ export const findDeleted = Effect.fn("Stack.findDeleted")( const [unlisted] = probes.flatMap(({ engine, listed }) => Result.isFailure(listed) ? [ - `Unable to list ${engine === "docker" ? "Docker" : "Podman"} containers while looking for stack ${options.id}'s leftovers: ${listed.failure.message}`, + { + message: `Unable to list ${engine === "docker" ? "Docker" : "Podman"} containers while looking for stack ${options.id}'s leftovers: ${listed.failure.message}`, + kind: failureKind(listed.failure), + }, ] : [], ); if (matched.length === 0) { if (unlisted === undefined) return Option.none(); - return yield* failure("find", unlisted); + return yield* failure("find", unlisted.message, unlisted.kind); } const destroy = Effect.gen(function* () { for (const { engine } of matched) { const refusal = Match.value(yield* reclaimStack({ ...options, state, engine })).pipe( Match.when("reclaimed", () => undefined), - Match.when("held", () => "Another process holds this stack's lease; run destroy again"), - Match.when("registered", () => `Stack ${options.id} was registered again during destroy`), + Match.when("held", () => ({ + message: "Another process holds this stack's lease; run destroy again", + kind: "lease-held" as const, + })), + Match.when("registered", () => ({ + message: `Stack ${options.id} was registered again during destroy`, + kind: "state" as const, + })), Match.exhaustive, ); - if (refusal !== undefined) return yield* failure("destroy", refusal); + if (refusal !== undefined) return yield* failure("destroy", refusal.message, refusal.kind); } if (unlisted !== undefined) return yield* failure( "destroy", - `Removed the ${matched.map(({ engine }) => (engine === "docker" ? "Docker" : "Podman")).join(" and ")} containers stack ${options.id} left behind. ${unlisted}`, + `Removed the ${matched.map(({ engine }) => (engine === "docker" ? "Docker" : "Podman")).join(" and ")} containers stack ${options.id} left behind. ${unlisted.message}`, + unlisted.kind, ); return { runtimeCleanup: "complete" } as const; }).pipe( diff --git a/packages/stack/src/internal/host-process.ts b/packages/stack/src/internal/host-process.ts index eb459d0c37..c2605e11ea 100644 --- a/packages/stack/src/internal/host-process.ts +++ b/packages/stack/src/internal/host-process.ts @@ -1,6 +1,7 @@ import { Cause, Effect, Exit, Option, Schema } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- readiness is an inherited launcher descriptor. import { closeSync, writeSync } from "node:fs"; +import { failureKind } from "../FailureKind.ts"; import { SavedStack } from "../State.ts"; import { runStackHost, StackHostError, type StackHostOptions } from "../StackHost.ts"; @@ -75,10 +76,12 @@ const program = (args: ReadonlyArray, overrides: Pick { const failure = Option.getOrUndefined(Cause.findErrorOption(cause)); + const kind = failureKind(cause); return report({ type: "error", message: failure?.message ?? Cause.pretty(cause), ...(failure?.reason === undefined ? {} : { reason: failure.reason }), + ...(kind === undefined ? {} : { kind }), }).pipe(Effect.exit, Effect.andThen(Effect.failCause(cause))); }), ); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 9028e812db..6cecef1ced 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -29,6 +29,7 @@ import { makeHostGateway, type ContainerProcess, } from "./Container.ts"; +import { failureKind } from "../FailureKind.ts"; const image = await Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -81,7 +82,10 @@ describe("container process adapter", () => { ); expect(Exit.isFailure(result)).toBe(true); expect(yield* Ref.get(pullAttempted)).toBe(true); - if (Exit.isFailure(result)) expect(Cause.pretty(result.cause)).toContain("pull rejected"); + if (Exit.isFailure(result)) { + expect(Cause.pretty(result.cause)).toContain("pull rejected"); + expect(failureKind(result.cause)).toBe("image-pull"); + } }).pipe(Effect.provide(NodeServices.layer)), ); @@ -720,6 +724,40 @@ describe("container process adapter", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("classifies a container start that lost its host port as a port conflict", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const result = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.flatMap((rejecting) => + rejecting.launch({ + image, + stackId: "c".repeat(64), + instanceId: "port-allocated", + env: {}, + args: ["-e", "process.exit(0)"], + ports: [8080], + }), + ), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makePortAllocatedStartSpawner(delegate), + ), + Effect.exit, + ); + const failure = Exit.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) + : undefined; + if (!(failure instanceof ContainerLaunchError)) + return yield* Effect.die("port conflict did not retain cleanup authority"); + yield* failure.process.remove; + expect(failureKind(failure)).toBe("port-allocation"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("keeps exit observation shared after a caller cancels its wait", () => Effect.scoped( Effect.gen(function* () { @@ -1359,6 +1397,27 @@ const makePullFailureSpawner = ( return delegate.spawn(command); }); +/** Fails `docker start` the way Docker reports a published host port another listener holds. */ +const makePortAllocatedStartSpawner = (delegate: ChildProcessSpawnerService["Service"]) => + ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && + command.command === "docker" && + command.args[0] === "start" + ? delegate.spawn( + ChildProcess.make( + process.execPath, + [ + "-e", + `console.error(${JSON.stringify( + "Error response from daemon: driver failed programming external connectivity on endpoint port-allocated: Bind for 127.0.0.1:54321 failed: port is already allocated", + )}); process.exit(1)`, + ], + { stdin: "ignore" }, + ), + ) + : delegate.spawn(command), + ); + const hostGatewayRejectionScript = `console.error(${JSON.stringify( 'Error response from daemon: invalid IP address in add-host: "host-gateway"', )}); process.exit(125)`; diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 092ffafd7c..866d40243d 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -20,6 +20,7 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type { StackFailureKind } from "../FailureKind.ts"; import { testRunLabelArgs as readTestRunLabelArgs } from "../internal/test-run-label.ts"; import { identifyContainer } from "./ContainerName.ts"; @@ -27,7 +28,9 @@ export class ContainerError extends Data.TaggedError("ContainerError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; + /** Set only where an unreachable engine changes the caller's recovery, such as stack sweeps. */ readonly reason?: "engine-unavailable"; + readonly kind?: StackFailureKind; }> {} interface ContainerSpec { @@ -126,13 +129,33 @@ const engineUnreachable = (error: ContainerError) => error.message, ); +/** The engine picks published host ports (`127.0.0.1::port`), so a refusal is its own allocation. */ +const portAllocated = (error: ContainerError) => + /port is already allocated|address already in use/iu.test(error.message); + +const engineError = (operation: string, cause: unknown): ContainerError => { + const error = errorFor(operation, cause); + const kind = + cause instanceof ContainerError + ? cause.kind + : engineUnreachable(error) + ? "engine-unavailable" + : portAllocated(error) + ? "port-allocation" + : undefined; + return kind === undefined + ? error + : new ContainerError({ operation, message: error.message, cause, kind }); +}; + const markUnavailable = (error: ContainerError) => - engineUnreachable(error) + error.kind === "engine-unavailable" ? new ContainerError({ operation: error.operation, message: error.message, cause: error.cause, reason: "engine-unavailable", + kind: error.kind, }) : error; @@ -328,7 +351,7 @@ export const makeContainerRuntime = (options: { ); yield* Effect.annotateCurrentSpan("process.exit_code", Number(code)); if (Number(code) !== 0) - return yield* errorFor( + return yield* engineError( args[0] ?? "command", stderr.trim() || `Engine exited with ${code}`, ); @@ -339,7 +362,7 @@ export const makeContainerRuntime = (options: { commandOptions.timeout === undefined ? effect : effect.pipe(Effect.timeout(commandOptions.timeout)), - Effect.mapError((cause) => errorFor(args[0] ?? "command", cause)), + Effect.mapError((cause) => engineError(args[0] ?? "command", cause)), ); }); @@ -601,6 +624,7 @@ export const makeContainerRuntime = (options: { operation: error.operation, message: `${error.message} (container name ${name})`, cause: error, + ...(error.kind === undefined ? {} : { kind: error.kind }), }), ), Effect.catchTag("TimeoutError", () => @@ -609,10 +633,11 @@ export const makeContainerRuntime = (options: { yield* run(["rm", "--force", name], { timeout: "10 seconds" }).pipe( Effect.ignore, ); - return yield* errorFor( - "create", - `Engine did not respond to container creation within ${CREATE_TIMEOUT} (container name ${name})`, - ); + return yield* new ContainerError({ + operation: "create", + message: `Engine did not respond to container creation within ${CREATE_TIMEOUT} (container name ${name})`, + kind: "engine-timeout", + }); }), ), ), @@ -861,7 +886,10 @@ const runCleanupCommand = Effect.fn("Container.runCleanupCommand")(function* ( ); yield* Effect.annotateCurrentSpan("process.exit_code", Number(code)); if (Number(code) !== 0) - return yield* errorFor(args[0] ?? "cleanup", stderr.trim() || `Engine exited with ${code}`); + return yield* engineError( + args[0] ?? "cleanup", + stderr.trim() || `Engine exited with ${code}`, + ); return stdout.trim(); }), ).pipe( @@ -869,14 +897,15 @@ const runCleanupCommand = Effect.fn("Container.runCleanupCommand")(function* ( duration: "30 seconds", orElse: () => Effect.fail( - errorFor( - args[0] ?? "cleanup", - `${engine} ${args[0] ?? "command"} did not respond within 30 seconds`, - ), + new ContainerError({ + operation: args[0] ?? "cleanup", + message: `${engine} ${args[0] ?? "command"} did not respond within 30 seconds`, + kind: "engine-timeout", + }), ), }), Effect.mapError((cause) => - cause instanceof ContainerError ? cause : errorFor(args[0] ?? "cleanup", cause), + cause instanceof ContainerError ? cause : engineError(args[0] ?? "cleanup", cause), ), ); }); diff --git a/packages/stack/src/runtime/Session.ts b/packages/stack/src/runtime/Session.ts index 3647e3aabe..fb70381c0f 100644 --- a/packages/stack/src/runtime/Session.ts +++ b/packages/stack/src/runtime/Session.ts @@ -1,10 +1,15 @@ import { Cause, Clock, Effect, Exit, Fiber, PubSub, Ref, Scope, Semaphore, Stream } from "effect"; +import type { StackFailureKind } from "../FailureKind.ts"; import { failureMessage } from "../internal/failure-message.ts"; import { ServiceError, type RuntimeSession } from "../Service.ts"; import type { ContainerProcess } from "./Container.ts"; /** Wraps an arbitrary failure as a `ServiceError`, special-casing Effect timeouts for a clearer message. */ -export const mapToServiceError = (operation: string, cause: unknown): ServiceError => +export const mapToServiceError = ( + operation: string, + cause: unknown, + kind?: StackFailureKind, +): ServiceError => cause instanceof ServiceError ? cause : new ServiceError({ @@ -13,6 +18,7 @@ export const mapToServiceError = (operation: string, cause: unknown): ServiceErr ? `Service ${operation} timed out` : failureMessage(cause), cause, + ...(kind === undefined ? {} : { kind }), }); /** A descendant outside the process group can keep stderr open after the launcher exits. */ diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index db7417d5a8..e1ce5bd102 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -356,7 +356,8 @@ const health = Effect.fn("Database.health")(function* ( ), Effect.timeoutOrElse({ duration: config.healthTimeoutMs ?? 60_000, - orElse: () => Effect.fail(errorFor("health", "Database readiness timed out")), + orElse: () => + Effect.fail(errorFor("health", "Database readiness timed out", "health-timeout")), }), Effect.mapError((cause) => errorFor("health", cause)), ); @@ -564,7 +565,9 @@ const nativeProcess = ( Effect.timeoutOrElse({ duration: config.healthTimeoutMs ?? 60_000, orElse: () => - Effect.fail(errorFor("launch", "PostgreSQL configuration probe timed out")), + Effect.fail( + errorFor("launch", "PostgreSQL configuration probe timed out", "timeout"), + ), }), ); return yield* spawnNativeProcess( diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 1845cef70e..32e308fe5e 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -242,6 +242,7 @@ const awaitStartup = Effect.fn("ProcessRecipe.awaitStartup")( `${service} startup timed out after ${startupTimeoutSeconds} seconds`, result.output, ), + "health-timeout", ) : Effect.succeed({ code: result.exitCode, output: result.output }), ), @@ -255,6 +256,7 @@ const startupFailure = ( serviceError( "launch", withRecentOutput(`${service} startup exited with ${result.code}`, result.output), + "process-exit", ); const isAddressInUse = (line: string) => @@ -656,6 +658,7 @@ export const makeProcessRecipe = : `${context.config.service} native listener bind was not confirmed`, yield* recentOutput(attempt), ), + "health-timeout", ); } @@ -672,6 +675,7 @@ export const makeProcessRecipe = const failure = serviceError( "launch", withRecentOutput(exitMessage, yield* recentOutput(attempt)), + "process-exit", ); const collided = Exit.isSuccess(observed.result) && @@ -693,6 +697,7 @@ export const makeProcessRecipe = failure: serviceError( "launch", `${context.config.service} native port collision\n${failure.message}`, + "port-allocation", ), }); }); diff --git a/packages/stack/src/shutdown.integration.test.ts b/packages/stack/src/shutdown.integration.test.ts index 905d95750c..bd0a890cfe 100644 --- a/packages/stack/src/shutdown.integration.test.ts +++ b/packages/stack/src/shutdown.integration.test.ts @@ -124,6 +124,7 @@ it.live("Effect stop reports shutdown-exit when the acknowledged owner remains a expect(Schema.is(StackError)(error)).toBe(true); if (!Schema.is(StackError)(error)) return yield* Effect.die("unexpected shutdown error"); expect(error.operation).toBe("shutdown-exit"); + expect(error.kind).toBe("owner-exit"); expect(error.message).toContain("shutdown acknowledgement"); }), ).pipe(Effect.provide(layer)), From 57e47e936ff73c6d46ec27bd675e87fe6aeed0e0 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 8 Oct 2026 11:43:22 +0200 Subject: [PATCH 2/6] fix(cli): keep wrapper fallbacks for unclassified stack failures An unclassified StackError no longer overrides the fallback preset of the db and test wrappers that carry it, and nested cause declarations share the classifier's depth budget and unwrap native failures. A pull counts as an unreachable engine only on daemon-specific evidence, so TLS errors stay pull failures. The port-allocation container test now injects its failure through the configured test engine, so it also runs under Podman. Co-Authored-By: Claude Opus 5.5 --- .../stack/destroy/destroy.handler.ts | 1 + .../stack/stack-errors.unit.test.ts | 8 ++++++ .../experimental/stack/stop/stop.handler.ts | 1 + .../shared/telemetry/error-actionability.ts | 28 +++++++++++++++---- .../src/runtime/Container.integration.test.ts | 4 +-- packages/stack/src/runtime/Container.ts | 5 ++-- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index e6b1681617..b8ca3ab5d8 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -198,6 +198,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( suggestion: runtime ? `Start the container engine, then run ${retry} again; nothing was removed for those stacks.` : `Resolve each error, then run ${retry} to retry the stacks that failed.`, + // Telemetry classifies a batch by its first failure; `detail` lists every stack. cause: failure.error, }); } diff --git a/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts b/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts index 02499f9b6b..125c988103 100644 --- a/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts @@ -131,6 +131,14 @@ describe("new stack error telemetry", () => { expect( classify(new FunctionsServeStackError({ reason: "runtime", message: "log stream ended" })), ).toMatchObject({ error_fingerprint: "tag:FunctionsServeStackError:runtime_stopped" }); + expect( + classify( + new DbDumpRunError({ + message: "x", + cause: new StackError({ operation: "status", message: "no kind" }), + }), + ), + ).toMatchObject({ error_fingerprint: "tag:DbDumpRunError", error_category: "db_connection" }); expect(classify(new TestDbRunError({ message: "exit 1" }))).toMatchObject({ error_fingerprint: "tag:TestDbRunError", error_category: "invalid_config", diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index 773036a5ff..dbcbedfdd5 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -138,6 +138,7 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: reason: "stack", message: `Failed to stop ${failures.length} managed stack(s).`, detail: failures.map(({ id, error }) => `${id}: ${error.message}`).join("\n"), + // Telemetry classifies a batch by its first failure; `detail` lists every stack. cause: firstFailure.error, }); const stopped = results.flatMap(({ id, result }) => diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 6335c1126d..c30936b007 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -962,14 +962,30 @@ const externalActionabilityByTag: Record = { * own declaration, else its external adapter. */ export function causeDeclaration(cause: unknown): CliErrorActionabilityDeclaration | undefined { - const declared = readDeclaration(cause); - if (declared !== undefined) return declared; - const tag = readErrorTag(cause); - if (tag === undefined || !isErrorRecord(cause) || !Object.hasOwn(externalActionabilityByTag, tag)) - return undefined; - return externalActionabilityByTag[tag]?.(cause); + // Wrapper getters call back into this, so nesting shares the classifier's depth budget. + if (causeDeclarationDepth >= MAX_CAUSE_DEPTH) return undefined; + causeDeclarationDepth++; + try { + const error = unwrapNativeFailure(cause); + const declared = readDeclaration(error); + if (declared !== undefined) return declared; + const tag = readErrorTag(error); + if ( + tag === undefined || + !isErrorRecord(error) || + !Object.hasOwn(externalActionabilityByTag, tag) + ) + return undefined; + const external = externalActionabilityByTag[tag]?.(error); + // An unclassified stack failure leaves the wrapper's own fallback in charge. + return external === unclassifiedStackFailureActionability ? undefined : external; + } finally { + causeDeclarationDepth--; + } } +let causeDeclarationDepth = 0; + function classifyShowHelp(error: ErrorRecord, depth: number): CliErrorActionability | undefined { const errors = error["errors"]; if (!Array.isArray(errors)) return undefined; diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 9def7cda50..1ec5943e8c 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -1730,11 +1730,11 @@ const makePullFailureSpawner = ( return delegate.spawn(command); }); -/** Fails `docker start` the way Docker reports a published host port another listener holds. */ +/** Fails the engine's `start` the way Docker reports a published host port another listener holds. */ const makePortAllocatedStartSpawner = (delegate: ChildProcessSpawnerService["Service"]) => ChildProcessSpawner.make((command) => ChildProcess.isStandardCommand(command) && - command.command === "docker" && + command.command === testEngine && command.args[0] === "start" ? delegate.spawn( ChildProcess.make( diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index c5a5e2f82c..e8e3ab3918 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -102,8 +102,9 @@ const pullEngineUnreachable = (cause: unknown, message: string) => (cause instanceof Object && "cause" in cause && cause.cause instanceof PlatformError.PlatformError && - cause.cause.reason._tag === "NotFound") || - /cannot connect to the docker daemon|error during connect|unable to connect to podman socket/iu.test( + cause.cause.reason._tag === "NotFound" && + cause.cause.reason.method === "spawn") || + /cannot connect to the docker daemon|connect: no such file or directory|error during connect:[^\n]*(?:docker daemon is not running|the system cannot find the file specified)|unable to connect to podman socket:[^\n]*the system cannot find the file specified/iu.test( message, ); From 18219773e9e078c62a7f46a4327920a9a98ee3f8 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 8 Oct 2026 11:52:43 +0200 Subject: [PATCH 3/6] fix(stack): keep engine causes when classifying pull failures A pull that hits a refused Podman socket is an unreachable engine, and a pull whose nested cause is a timeout stays an engine timeout instead of being reported as an image pull failure. Co-Authored-By: Claude Opus 5.5 --- .../shared/telemetry/error-actionability.ts | 2 +- packages/stack/src/FailureKind.ts | 7 ++++- packages/stack/src/FailureKind.unit.test.ts | 9 ++++++ .../src/runtime/Container.integration.test.ts | 29 ++++++++++++++++--- packages/stack/src/runtime/Container.ts | 2 +- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index c30936b007..e65c392421 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -962,7 +962,7 @@ const externalActionabilityByTag: Record = { * own declaration, else its external adapter. */ export function causeDeclaration(cause: unknown): CliErrorActionabilityDeclaration | undefined { - // Wrapper getters call back into this, so nesting shares the classifier's depth budget. + // Wrapper getters call back into this; its own counter bounds that nesting at MAX_CAUSE_DEPTH. if (causeDeclarationDepth >= MAX_CAUSE_DEPTH) return undefined; causeDeclarationDepth++; try { diff --git a/packages/stack/src/FailureKind.ts b/packages/stack/src/FailureKind.ts index 648b8570dd..fbeb21d762 100644 --- a/packages/stack/src/FailureKind.ts +++ b/packages/stack/src/FailureKind.ts @@ -120,7 +120,12 @@ const classify = ( return value.reason === "runtime-unavailable" ? "engine-unavailable" : undefined; case "ContainerError": { const inner = visit(value.cause, "engine"); - if (value.operation === "pull" && inner !== "engine-unavailable") return "image-pull"; + if ( + value.operation === "pull" && + inner !== "engine-unavailable" && + inner !== "engine-timeout" + ) + return "image-pull"; return inner ?? "engine-command"; } case "ContainerLaunchError": diff --git a/packages/stack/src/FailureKind.unit.test.ts b/packages/stack/src/FailureKind.unit.test.ts index cc28fc3aaa..639e48dea0 100644 --- a/packages/stack/src/FailureKind.unit.test.ts +++ b/packages/stack/src/FailureKind.unit.test.ts @@ -67,6 +67,15 @@ const cases: ReadonlyArray { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("classifies a pull through a refused Podman socket as an unreachable engine", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const token = yield* (yield* Crypto.Crypto).randomUUIDv4; + const spawner = makePullFailureSpawner( + delegate, + yield* Ref.make(false), + "Error: unable to connect to Podman socket: dial unix /run/user/1000/podman/podman.sock: connect: connection refused", + ); + const result = yield* makeContainerRuntime({ target: containerTarget, root: "." }).pipe( + Effect.flatMap((runtime) => + runtime.prepare(`supabase-prepare-regression:${token}`).pipe(Effect.exit), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + if (Exit.isSuccess(result)) return yield* Effect.die("pull unexpectedly succeeded"); + expect(failureKind(result.cause)).toBe("engine-unavailable"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("keeps missing image pull failures observable", () => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -1704,9 +1724,13 @@ const repoDigest = (spawner: ChildProcessSpawnerService["Service"], image: strin return digest; }); +const registryRefusedStderr = + "pull rejected by cached-image regression: dial tcp 127.0.0.1:9: connect: connection refused"; + const makePullFailureSpawner = ( delegate: ChildProcessSpawnerService["Service"], pullAttempted: Ref.Ref, + stderr = registryRefusedStderr, ) => ChildProcessSpawner.make((command) => { if ( @@ -1719,10 +1743,7 @@ const makePullFailureSpawner = ( return yield* delegate.spawn( ChildProcess.make( process.execPath, - [ - "-e", - "console.error('pull rejected by cached-image regression: dial tcp 127.0.0.1:9: connect: connection refused'); process.exit(73)", - ], + ["-e", `console.error(${JSON.stringify(stderr)}); process.exit(73)`], { stdin: "ignore" }, ), ); diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index e8e3ab3918..a42e61af60 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -104,7 +104,7 @@ const pullEngineUnreachable = (cause: unknown, message: string) => cause.cause instanceof PlatformError.PlatformError && cause.cause.reason._tag === "NotFound" && cause.cause.reason.method === "spawn") || - /cannot connect to the docker daemon|connect: no such file or directory|error during connect:[^\n]*(?:docker daemon is not running|the system cannot find the file specified)|unable to connect to podman socket:[^\n]*the system cannot find the file specified/iu.test( + /cannot connect to the docker daemon|connect: no such file or directory|error during connect:[^\n]*(?:docker daemon is not running|the system cannot find the file specified)|unable to connect to podman socket:[^\n]*(?:the system cannot find the file specified|connection refused)/iu.test( message, ); From 5aae41fdb797109b35ae9b3f4846a2959a0e543f Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 8 Oct 2026 11:53:49 +0200 Subject: [PATCH 4/6] test(stack): pass pull failure stderr through the environment Co-Authored-By: Claude Opus 5.5 --- packages/stack/src/runtime/Container.integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index b723561022..972904309e 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -1743,8 +1743,8 @@ const makePullFailureSpawner = ( return yield* delegate.spawn( ChildProcess.make( process.execPath, - ["-e", `console.error(${JSON.stringify(stderr)}); process.exit(73)`], - { stdin: "ignore" }, + ["-e", "console.error(process.env.PULL_STDERR); process.exit(73)"], + { stdin: "ignore", env: { ...process.env, PULL_STDERR: stderr } }, ), ); }); From 44a755b97d2882629426c7a96a0ce0db812ade9f Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 8 Oct 2026 12:06:32 +0200 Subject: [PATCH 5/6] fix(cli): keep reason suffixes and port evidence in stack telemetry `functions serve` keeps its lifecycle or runtime suffix when a wrapped cause carries none. A pinned port is a port conflict only when the bind reports a conflict; other pinned bind failures are configuration errors. A pull that reaches a refused Docker TCP daemon is an unreachable engine. Co-Authored-By: Claude Opus 5.5 --- .../stack/stack-errors.unit.test.ts | 12 +++++ .../commands/functions/serve/serve.errors.ts | 26 ++++++----- packages/stack/src/Ports.integration.test.ts | 3 +- packages/stack/src/Ports.ts | 7 ++- .../src/runtime/Container.integration.test.ts | 46 +++++++++++-------- packages/stack/src/runtime/Container.ts | 2 +- 6 files changed, 63 insertions(+), 33 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts b/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts index 125c988103..3b489ed9fb 100644 --- a/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts @@ -131,6 +131,18 @@ describe("new stack error telemetry", () => { expect( classify(new FunctionsServeStackError({ reason: "runtime", message: "log stream ended" })), ).toMatchObject({ error_fingerprint: "tag:FunctionsServeStackError:runtime_stopped" }); + expect( + classify( + new FunctionsServeStackError({ + reason: "lifecycle", + message: "x", + cause: new LocalDbRunningError({ message: "not running" }), + }), + ), + ).toMatchObject({ + error_fingerprint: "tag:FunctionsServeStackError:lifecycle", + error_category: "invalid_config", + }); expect( classify( new DbDumpRunError({ diff --git a/apps/cli/src/commands/functions/serve/serve.errors.ts b/apps/cli/src/commands/functions/serve/serve.errors.ts index cfe6e04eaa..1dc561efb1 100644 --- a/apps/cli/src/commands/functions/serve/serve.errors.ts +++ b/apps/cli/src/commands/functions/serve/serve.errors.ts @@ -7,6 +7,16 @@ import { unclassifiedStackFailureActionability, } from "../../../shared/telemetry/error-actionability.ts"; +/** Keeps a cause's own classification, falling back to this reason's suffix when it has none. */ +const withSuffix = ( + declared: CliErrorActionabilityDeclaration | undefined, + fallback: CliErrorActionabilityDeclaration, + suffix: "lifecycle" | "runtime_stopped", +): CliErrorActionabilityDeclaration => + declared === undefined + ? { ...fallback, fingerprint_suffix: suffix } + : { ...declared, fingerprint_suffix: declared.fingerprint_suffix ?? suffix }; + export class FunctionsServeStackError extends Data.TaggedError("FunctionsServeStackError")<{ readonly reason: "flags" | "invalid-config" | "lifecycle" | "runtime" | "stack"; readonly message: string; @@ -20,18 +30,12 @@ export class FunctionsServeStackError extends Data.TaggedError("FunctionsServeSt case "invalid-config": return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "lifecycle": - return ( - causeDeclaration(this.cause) ?? { - ...actionability.startStack, - fingerprint_suffix: "lifecycle", - } - ); + return withSuffix(causeDeclaration(this.cause), actionability.startStack, "lifecycle"); case "runtime": - return ( - causeDeclaration(this.cause) ?? { - ...actionability.runtimeCrash, - fingerprint_suffix: "runtime_stopped", - } + return withSuffix( + causeDeclaration(this.cause), + actionability.runtimeCrash, + "runtime_stopped", ); case "stack": return causeDeclaration(this.cause) ?? unclassifiedStackFailureActionability; diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index b864f8f7d8..cba7f85643 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -661,13 +661,14 @@ it.live("a pinned port that fails to bind is not left reserved", () => const { ports, registry, stateRoot, a } = yield* twoProjects; const port = pinnedPort(); - yield* ports + const failure = yield* ports .acquire({ stackId: a, key: "db:sql", host: "127.0.0.1", port }, () => Effect.fail(new PortError({ key: "db:sql", message: "bind failed" })), ) .pipe(Effect.flip); expect(yield* registry.find(stateRoot, a, "db:sql")).toBeUndefined(); + expect(failureKind(failure)).toBe("configuration"); }), ).pipe(withRegistry), ); diff --git a/packages/stack/src/Ports.ts b/packages/stack/src/Ports.ts index f08eed89e5..879efeb266 100644 --- a/packages/stack/src/Ports.ts +++ b/packages/stack/src/Ports.ts @@ -470,7 +470,12 @@ export const makePorts = (state: StackNamespace.Interface) => message: error.value.message, ...(error.value.cause === undefined ? {} : { cause: error.value.cause }), ...(error.value.conflict === undefined ? {} : { conflict: error.value.conflict }), - kind: request.port === "auto" ? "port-allocation" : "port-conflict", + kind: + request.port === "auto" + ? "port-allocation" + : error.value.conflict !== undefined + ? "port-conflict" + : (error.value.kind ?? "configuration"), }); } diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 972904309e..8948fdc1a8 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -91,25 +91,33 @@ describe("container process adapter", { timeout: 120_000 }, () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("classifies a pull through a refused Podman socket as an unreachable engine", () => - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const token = yield* (yield* Crypto.Crypto).randomUUIDv4; - const spawner = makePullFailureSpawner( - delegate, - yield* Ref.make(false), - "Error: unable to connect to Podman socket: dial unix /run/user/1000/podman/podman.sock: connect: connection refused", - ); - const result = yield* makeContainerRuntime({ target: containerTarget, root: "." }).pipe( - Effect.flatMap((runtime) => - runtime.prepare(`supabase-prepare-regression:${token}`).pipe(Effect.exit), - ), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - if (Exit.isSuccess(result)) return yield* Effect.die("pull unexpectedly succeeded"); - expect(failureKind(result.cause)).toBe("engine-unavailable"); - }).pipe(Effect.provide(NodeServices.layer)), - ); + for (const [engine, stderr] of [ + [ + "Podman socket", + "Error: unable to connect to Podman socket: dial unix /run/user/1000/podman/podman.sock: connect: connection refused", + ], + [ + "Docker TCP daemon", + 'error during connect: Head "http://127.0.0.1:2375/_ping": dial tcp 127.0.0.1:2375: connect: connection refused', + ], + ] as const) + it.live( + `classifies a pull through a refused ${engine} connection as an unreachable engine`, + () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const token = yield* (yield* Crypto.Crypto).randomUUIDv4; + const spawner = makePullFailureSpawner(delegate, yield* Ref.make(false), stderr); + const result = yield* makeContainerRuntime({ target: containerTarget, root: "." }).pipe( + Effect.flatMap((runtime) => + runtime.prepare(`supabase-prepare-regression:${token}`).pipe(Effect.exit), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + if (Exit.isSuccess(result)) return yield* Effect.die("pull unexpectedly succeeded"); + expect(failureKind(result.cause)).toBe("engine-unavailable"); + }).pipe(Effect.provide(NodeServices.layer)), + ); it.live("keeps missing image pull failures observable", () => Effect.gen(function* () { diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index a42e61af60..c00132e8c2 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -104,7 +104,7 @@ const pullEngineUnreachable = (cause: unknown, message: string) => cause.cause instanceof PlatformError.PlatformError && cause.cause.reason._tag === "NotFound" && cause.cause.reason.method === "spawn") || - /cannot connect to the docker daemon|connect: no such file or directory|error during connect:[^\n]*(?:docker daemon is not running|the system cannot find the file specified)|unable to connect to podman socket:[^\n]*(?:the system cannot find the file specified|connection refused)/iu.test( + /cannot connect to the docker daemon|connect: no such file or directory|error during connect:[^\n]*(?:docker daemon is not running|the system cannot find the file specified|connection refused)|unable to connect to podman socket:[^\n]*(?:the system cannot find the file specified|connection refused)/iu.test( message, ); From 812c6953f4450049ed0bf4a323db7e9280248e77 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 8 Oct 2026 12:22:47 +0200 Subject: [PATCH 6/6] fix(stack): separate pull deadlines and filesystem permission failures A pull that hits its download deadline is an image pull failure, not a stalled engine. Only a permission-denied filesystem failure is reported as a permission error; other filesystem failures are classified as runtime failures. A bind callback's kind survives the port listener wrapper, and `functions serve` reports a saved-credential decode failure as stack state. Co-Authored-By: Claude Opus 5.5 --- .../commands/functions/serve/serve.errors.ts | 4 +++- .../functions/serve/serve.stack.handler.ts | 13 +++++----- .../shared/telemetry/error-actionability.ts | 4 +++- packages/stack/src/FailureKind.ts | 21 ++++++++-------- packages/stack/src/FailureKind.unit.test.ts | 24 +++++++++++++++---- packages/stack/src/Ports.ts | 1 + packages/stack/src/runtime/Container.ts | 4 +++- 7 files changed, 47 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/commands/functions/serve/serve.errors.ts b/apps/cli/src/commands/functions/serve/serve.errors.ts index 1dc561efb1..85166efdcb 100644 --- a/apps/cli/src/commands/functions/serve/serve.errors.ts +++ b/apps/cli/src/commands/functions/serve/serve.errors.ts @@ -18,7 +18,7 @@ const withSuffix = ( : { ...declared, fingerprint_suffix: declared.fingerprint_suffix ?? suffix }; export class FunctionsServeStackError extends Data.TaggedError("FunctionsServeStackError")<{ - readonly reason: "flags" | "invalid-config" | "lifecycle" | "runtime" | "stack"; + readonly reason: "flags" | "invalid-config" | "state" | "lifecycle" | "runtime" | "stack"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -29,6 +29,8 @@ export class FunctionsServeStackError extends Data.TaggedError("FunctionsServeSt return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; case "invalid-config": return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + case "state": + return { ...actionability.invalidConfig, fingerprint_suffix: "state_file" }; case "lifecycle": return withSuffix(causeDeclaration(this.cause), actionability.startStack, "lifecycle"); case "runtime": diff --git a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts index 0ce07b7a0f..0568e32e4b 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts @@ -35,13 +35,14 @@ const runtimeError = (cause: { readonly message: string }) => }) : new FunctionsServeStackError({ reason: - cause instanceof StackFunctionsEnvError || - cause instanceof StackConfigError || - Schema.isSchemaError(cause) + cause instanceof StackFunctionsEnvError || cause instanceof StackConfigError ? "invalid-config" - : cause instanceof LocalDbRunningError - ? "lifecycle" - : "runtime", + : // The session only decodes the stack's saved credentials. + Schema.isSchemaError(cause) + ? "state" + : cause instanceof LocalDbRunningError + ? "lifecycle" + : "runtime", message: cause.message, cause, }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index e65c392421..3ed6f89edd 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -106,6 +106,7 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "engine_command", "engine_timeout", "filesystem", + "filesystem_io", "flags", "forbidden", "gateway_auth", @@ -505,7 +506,8 @@ const stackFailureKindActionability = { }, configuration: { ...actionability.invalidConfig, fingerprint_suffix: "stack_configuration" }, state: { ...actionability.invalidConfig, fingerprint_suffix: "state_file" }, - filesystem: { ...actionability.permission, fingerprint_suffix: "filesystem" }, + "filesystem-permission": { ...actionability.permission, fingerprint_suffix: "filesystem" }, + filesystem: { ...actionability.runtimeCrash, fingerprint_suffix: "filesystem_io" }, "owner-startup": { ...actionability.runtimeCrash, fingerprint_suffix: "owner_startup" }, "owner-connection": { ...actionability.runtimeCrash, fingerprint_suffix: "owner_connection" }, "owner-exit": { ...actionability.runtimeCrash, fingerprint_suffix: "owner_exit" }, diff --git a/packages/stack/src/FailureKind.ts b/packages/stack/src/FailureKind.ts index fbeb21d762..3ba1efafba 100644 --- a/packages/stack/src/FailureKind.ts +++ b/packages/stack/src/FailureKind.ts @@ -22,6 +22,7 @@ const stackFailureKinds = [ "database-bootstrap", "configuration", "state", + "filesystem-permission", "filesystem", "owner-startup", "owner-connection", @@ -43,7 +44,12 @@ export const isStackFailureKind = (value: unknown): value is StackFailureKind => typeof value === "string" && knownKinds.has(value); /** Kinds that only describe the mechanism, so an enclosing domain error names the failure better. */ -const generalKinds = new Set(["filesystem", "timeout", "configuration"]); +const generalKinds = new Set([ + "filesystem-permission", + "filesystem", + "timeout", + "configuration", +]); const maxDepth = 32; @@ -120,12 +126,8 @@ const classify = ( return value.reason === "runtime-unavailable" ? "engine-unavailable" : undefined; case "ContainerError": { const inner = visit(value.cause, "engine"); - if ( - value.operation === "pull" && - inner !== "engine-unavailable" && - inner !== "engine-timeout" - ) - return "image-pull"; + // A pull's own deadline is the registry download, not a stalled engine. + if (value.operation === "pull" && inner !== "engine-unavailable") return "image-pull"; return inner ?? "engine-command"; } case "ContainerLaunchError": @@ -197,9 +199,8 @@ const classify = ( return undefined; } case "PlatformError": - return isRecord(value.reason) && value.reason.module === "FileSystem" - ? "filesystem" - : undefined; + if (!isRecord(value.reason) || value.reason.module !== "FileSystem") return undefined; + return value.reason._tag === "PermissionDenied" ? "filesystem-permission" : "filesystem"; default: return nested(); } diff --git a/packages/stack/src/FailureKind.unit.test.ts b/packages/stack/src/FailureKind.unit.test.ts index 639e48dea0..4c4531efd6 100644 --- a/packages/stack/src/FailureKind.unit.test.ts +++ b/packages/stack/src/FailureKind.unit.test.ts @@ -68,13 +68,13 @@ const cases: ReadonlyArray key: request.key, message: `Cannot bind ${request.key} at ${request.host}:${port}: ${cause.message}`, cause: cause.cause, + ...(cause.kind === undefined ? {} : { kind: cause.kind }), conflict: cause.conflict ?? (isAddressInUse(cause.cause) diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index c00132e8c2..5ac71eb26b 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -116,7 +116,9 @@ const engineKind = ( cause instanceof ContainerError ? cause.kind : Cause.isTimeoutError(cause) - ? "engine-timeout" + ? operation === "pull" + ? "image-pull" + : "engine-timeout" : (operation === "pull" ? pullEngineUnreachable(cause, message) : engineUnreachable(cause)) ? "engine-unavailable" : portAllocated(message)