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 bc1e17682b..76e66357b1 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -37,8 +37,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; @@ -99,7 +106,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), ), @@ -180,13 +187,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."); @@ -210,7 +213,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 7c42498ee1..63ff200175 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 060dc34531..6bfcffdb2c 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 : yield* 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..532fcfcee0 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,14 @@ 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" }; + const declared = causeDeclaration(this.cause); + return declared === undefined + ? { ...actionability.dbConnection, fingerprint_suffix: "connect" } + : { ...declared, fingerprint_suffix: declared.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..2f4c73fe27 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" | "runtime" | "stack"; readonly message: string; readonly detail?: string; readonly suggestion?: string; @@ -22,17 +17,17 @@ 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; + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; case "runtime": - return actionability.dockerNotRunning; - case "unknown": - return actionability.unknown; + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + 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 9ea38b338f..b8ca3ab5d8 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -27,7 +27,7 @@ const destroyError = (id: string) => (cause: StackError) => cause, }) : new StackCommandDestroyError({ - reason: "unknown", + reason: "stack", message: cause.message, cause, }); @@ -192,13 +192,14 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( const retry = `"supabase stack destroy ${failed.map(({ id }) => `--stack-id ${id}`).join(" ")} --yes"`; const runtime = failed.every(({ error }) => error.reason === "runtime"); return yield* new StackCommandDestroyError({ - reason: runtime ? "runtime" : "unknown", + reason: runtime ? "runtime" : "stack", message: `Failed to destroy ${failed.length} managed stack(s).`, detail: failed.map(({ id, error }) => `${id}: ${error.message}`).join("\n"), 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.`, - cause: failed, + // Telemetry classifies a batch by its first failure; `detail` lists every stack. + cause: failure.error, }); } if (batch && output.format !== "text") diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 2be6e96216..c9d5010ca2 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -464,7 +464,7 @@ describe("stack destroy", () => { Effect.flip, ); - expect(error).toMatchObject({ reason: "unknown", message: "engine refused" }); + expect(error).toMatchObject({ reason: "stack", message: "engine refused" }); expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ f.stack.id, ]); @@ -489,7 +489,7 @@ describe("stack destroy", () => { ); expect(yield* destroyWith([f.stack.id])).toMatchObject({ - reason: "unknown", + reason: "stack", suggestion: `Resolve each error, then run ${retry} to retry the stacks that failed.`, }); expect(yield* destroyWith(ids)).toMatchObject({ 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 c6c65ab47e..1e7e1e9bd3 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -38,7 +38,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 24db98af06..707df6aa22 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 @@ -86,7 +86,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, ), ), @@ -292,7 +298,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..3b489ed9fb --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-errors.unit.test.ts @@ -0,0 +1,163 @@ +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 { DeclarativeApplyError } from "../../db/schema/declarative/declarative.errors.ts"; +import { FunctionsServeStackError } from "../../functions/serve/serve.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 DeclarativeApplyError({ + message: "x", + connect: true, + cause: new LocalDbRunningError({ message: "not running" }), + }), + ), + ).toMatchObject({ error_fingerprint: "tag:DeclarativeApplyError:connect" }); + 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({ + 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", + }); + 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 d924692707..d91576100f 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -36,7 +36,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 45a3dfd5e4..caa8a58c9c 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -132,7 +132,7 @@ const stackError = ( }); const suggestion = portConflictSuggestion(cause.conflict, requested); return new StackCommandStartError({ - reason: "unknown", + reason: "stack", message: cause.message, ...(detail === undefined ? {} : { detail }), ...(suggestion === undefined ? {} : { suggestion }), 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 cdfaa5c9f1..918462bcc1 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -83,23 +83,23 @@ type StackReport = { readonly env: Readonly>; }; +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 38573871d8..dbcbedfdd5 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -132,12 +132,14 @@ 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, + // Telemetry classifies a batch by its first failure; `detail` lists every stack. + 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 7fd6437ec8..c8ee078a28 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 @@ -141,7 +141,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); @@ -166,7 +166,7 @@ describe("stack stop", () => { Effect.flip, Effect.ensuring(Scope.close(holder, Exit.void)), ); - expect(error.reason).toBe("unknown"); + expect(error.reason).toBe("stack"); expect(error.detail).toContain(`${f.stack.id}: Another process holds the stack lease`); expect(f.output.stdoutText).toBe(""); expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ diff --git a/apps/cli/src/commands/functions/serve/serve.errors.ts b/apps/cli/src/commands/functions/serve/serve.errors.ts index 1c6ef725f1..85166efdcb 100644 --- a/apps/cli/src/commands/functions/serve/serve.errors.ts +++ b/apps/cli/src/commands/functions/serve/serve.errors.ts @@ -1,12 +1,24 @@ import { Data } from "effect"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + 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"; + readonly reason: "flags" | "invalid-config" | "state" | "lifecycle" | "runtime" | "stack"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -14,13 +26,21 @@ 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 "state": + return { ...actionability.invalidConfig, fingerprint_suffix: "state_file" }; case "lifecycle": - return actionability.startStack; + return withSuffix(causeDeclaration(this.cause), actionability.startStack, "lifecycle"); case "runtime": - return actionability.unknown; + return withSuffix( + causeDeclaration(this.cause), + actionability.runtimeCrash, + "runtime_stopped", + ); + 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..0568e32e4b 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,25 @@ 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 + ? "invalid-config" + : // The session only decodes the stack's saved credentials. + Schema.isSchemaError(cause) + ? "state" + : 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..3ed6f89edd 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. @@ -77,6 +78,7 @@ const CLI_SUGGESTED_COMMANDS = [ type CliSuggestedCommand = (typeof CLI_SUGGESTED_COMMANDS)[number]; const CLI_ERROR_FINGERPRINT_SUFFIXES = [ + "already_exists", "api_response", "api_status", "asset_checksum", @@ -84,43 +86,69 @@ 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", + "filesystem_io", + "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", + "runtime_stopped", "saml_disabled", + "seed_buckets", + "stack_configuration", + "state_file", + "unclassified", ] as const; type CliErrorFingerprintSuffix = (typeof CLI_ERROR_FINGERPRINT_SUFFIXES)[number]; @@ -448,6 +476,71 @@ 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-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" }, + "lease-held": { ...actionability.invalidInput, fingerprint_suffix: "lease_held" }, + "already-exists": { ...actionability.invalidInput, fingerprint_suffix: "already_exists" }, + 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 +937,57 @@ 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 { + // 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 { + 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/Artifacts.ts b/packages/stack/src/Artifacts.ts index 58210002fe..378db82622 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. */ @@ -554,7 +556,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]; @@ -563,6 +568,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, @@ -621,6 +627,7 @@ const nativeStoreRequest = Effect.fn("Artifacts.nativeStoreRequest")(function* ( 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..3ba1efafba --- /dev/null +++ b/packages/stack/src/FailureKind.ts @@ -0,0 +1,214 @@ +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-permission", + "filesystem", + "owner-startup", + "owner-connection", + "owner-exit", + "lease-held", + "already-exists", + "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-permission", + "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"); + // 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": + 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 "Namespace.NamespaceError": + return within("state"); + case "Namespace.LeaseHeldError": + return "lease-held"; + case "Namespace.EnvironmentOverrideError": + case "Namespace.EnvironmentSymlinkError": + return "configuration"; + 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": + if (!isRecord(value.reason) || value.reason.module !== "FileSystem") return undefined; + return value.reason._tag === "PermissionDenied" ? "filesystem-permission" : "filesystem"; + 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..4c4531efd6 --- /dev/null +++ b/packages/stack/src/FailureKind.unit.test.ts @@ -0,0 +1,297 @@ +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 { LeaseHeldError, NamespaceError } from "./StackNamespace.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", + ], + [ + "a pull that hit its download deadline as image-pull", + new ContainerError({ + operation: "pull", + message: "timed out", + cause: new Cause.TimeoutError(), + }), + "image-pull", + ], + [ + "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 permission failure inside artifact preparation as filesystem-permission", + new ArtifactError({ message: "Unable to prepare auth artifact", cause: fileFailure }), + "filesystem-permission", + ], + [ + "a permission failure with no enclosing domain as filesystem-permission", + new ServiceError({ operation: "launch", message: "denied", cause: fileFailure }), + "filesystem-permission", + ], + [ + "a non-permission file failure as filesystem", + new ServiceError({ + operation: "launch", + message: "exists", + cause: PlatformError.systemError({ + _tag: "AlreadyExists", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: "/tmp/stack", + }), + }), + "filesystem", + ], + [ + "a state file failure as state", + new NamespaceError({ operation: "read", message: "denied", cause: fileFailure }), + "state", + ], + [ + "a live owner holding the stack lease as lease-held", + new LeaseHeldError({ stackId: "abc", message: "held" }), + "lease-held", + ], + [ + "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 bf27544438..6ffd96779f 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"; @@ -86,6 +87,7 @@ export class HostProcessError extends Data.TaggedError("HostProcessError")<{ readonly message: string; readonly cause?: unknown; readonly reason?: HostFailureReason; + readonly kind?: StackFailureKind; }> {} type HostFailureReason = | "unregistered" @@ -130,18 +132,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 = @@ -298,6 +310,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, ), @@ -359,6 +373,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), }), ]); @@ -451,12 +466,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(); @@ -539,11 +555,17 @@ const spawnOwner = Effect.fn("HostProcess.spawnOwner")(function* ( ); } return yield* line.reason === "lease-held" || line.reason === "exists" - ? error("startup", "Stack already exists; use open") + ? error( + "startup", + "Stack already exists; use open", + undefined, + line.reason === "exists" ? "already-exists" : "lease-held", + ) : error( "startup", `Stack owner failed to start: ${line.message} (owner log: ${log})`, line.reason, + isStackFailureKind(line.kind) ? line.kind : undefined, ); } if (line.endpoint.stackId !== options.stackId) diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index bd75ac0008..cba7f85643 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -24,6 +24,7 @@ import { probeVacant, reserveNativePort, } from "./Ports.ts"; +import { failureKind } from "./FailureKind.ts"; import { systemError } from "effect/PlatformError"; import { bindTcp } from "./Proxy.ts"; import { CONTAINER_ENV_DIRNAME } from "./namespace/Paths.ts"; @@ -197,6 +198,7 @@ it.live( .pipe(Effect.flip); if (!(failure instanceof PortError)) return yield* Effect.die("expected a PortError"); expect(failure.conflict?.holder).toBe("foreign"); + expect(failureKind(failure)).toBe("port-conflict"); }), ).pipe( Effect.provide( @@ -535,6 +537,33 @@ it.live("a stopped stack's pinned port is free for another project's stack", () ).pipe(withRegistry), ); +it.live("a stopped stack's own auto port taken by another process is the stack's allocation", () => + Effect.scoped( + Effect.gen(function* () { + const { ports, a } = yield* twoProjects; + const request = { stackId: a, key: "api", host: "127.0.0.1", port: "auto" as const }; + const first = yield* Effect.scoped( + ports.acquire(request, (_host, bound) => Effect.succeed(bound)), + ); + + const failure = yield* ports + .acquire(request, (_host, port) => + Effect.fail( + new PortError({ + key: "api", + message: `Port ${port} is already in use`, + conflict: { port, endpoint: "api", holder: "foreign" }, + }), + ), + ) + .pipe(Effect.flip); + + expect(failure.message).toContain(String(first.port)); + expect(failureKind(failure)).toBe("port-allocation"); + }), + ).pipe(withRegistry), +); + it.live("a running stack's pinned port conflict names the port, its project, and the fix", () => Effect.scoped( Effect.gen(function* () { @@ -554,6 +583,7 @@ it.live("a running stack's pinned port conflict names the port, its project, and expect(failure.message).not.toContain("destroy"); if (!(failure instanceof PortError)) return yield* Effect.die("expected a PortError"); expect(failure.conflict?.holder).toMatchObject({ stackId: a }); + expect(failureKind(failure)).toBe("port-conflict"); }), ).pipe(withRegistry), ); @@ -631,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 025ad27fe0..99195ddc2b 100644 --- a/packages/stack/src/Ports.ts +++ b/packages/stack/src/Ports.ts @@ -12,6 +12,7 @@ import { Scope, } from "effect"; import * as Net from "node:net"; +import type { StackFailureKind } from "./FailureKind.ts"; import type * as StackNamespace from "./StackNamespace.ts"; import * as Lease from "./namespace/Lease.ts"; import * as PortReservations from "./namespace/PortReservations.ts"; @@ -30,11 +31,13 @@ export interface PortConflict { readonly holder: Holder | "foreign"; } +/** 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 conflict?: PortConflict; + readonly kind?: StackFailureKind; }> {} export interface PortRequest { @@ -245,17 +248,21 @@ const resolveRequest = ( new PortError({ key: request.key, message: "The requested listener differs from its reserved assignment", + kind: "configuration", }), ); const requested = owned ?? request.port; if (requested === "auto") return Effect.succeed(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 (requested >= nativePortBase && requested < portBase) return Effect.fail( new PortError({ key: request.key, message: `Public port ${requested} for ${request.key} is inside ${nativePortBase}-${portBase - 1}, which is reserved for native service backends; configure a port outside that range`, + kind: "configuration", }), ); return Effect.succeed(requested); @@ -369,7 +376,11 @@ export const makePorts = (state: StackNamespace.Interface) => 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 owned = yield* portReservations.find(stateRoot, request.stackId, request.key); const requested = yield* resolveRequest(request, owned); const owner = yield* Scope.Scope; @@ -389,6 +400,7 @@ export const makePorts = (state: StackNamespace.Interface) => 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) @@ -452,7 +464,20 @@ export const makePorts = (state: StackNamespace.Interface) => !(error.value instanceof PortError) ) return yield* Effect.failCause(result.cause); - return yield* error.value; + // Only a port the caller requested by number is the user's to change; an owned + // assignment from an earlier auto allocation is ours. + return yield* new PortError({ + key: error.value.key, + 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" + : error.value.conflict !== undefined + ? "port-conflict" + : (error.value.kind ?? "configuration"), + }); } // A freshly auto-reserved port that fails to bind (including by interruption mid-probe) 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 f2dda81329..f9e7af8ba1 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 "./StackNamespace.ts"; +import { failureKind } from "./FailureKind.ts"; import { failureMessage } from "./internal/failure-message.ts"; import type { PortConflict } from "./Ports.ts"; import { LogPosition, LogRecord } from "./host/LogRecord.ts"; @@ -13,6 +14,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), }); const ConflictHolder = Schema.Union([ @@ -38,10 +41,17 @@ 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), /** The contested public port, when the failure is a port reservation conflict. */ conflict: Schema.optionalKey(Conflict), }) {} +const kindOf = (cause: unknown) => { + const kind = failureKind(cause); + return kind === undefined ? {} : { kind }; +}; + const isHolder = ( value: unknown, ): value is { readonly stackId: string; readonly stateRoot: string } => @@ -92,8 +102,11 @@ 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), ...(conflict === undefined ? {} : { conflict }), }); } @@ -101,6 +114,7 @@ export const stackError = (operation: string, cause: unknown): StackError => { return new StackError({ operation, message: failureMessage(cause), + ...kindOf(cause), ...(conflict === undefined ? {} : { conflict }), }); }; 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 4c9d8e0330..f99723a814 100644 --- a/packages/stack/src/Service.ts +++ b/packages/stack/src/Service.ts @@ -11,6 +11,7 @@ import { SubscriptionRef, } from "effect"; import type { Stream } from "effect"; +import type { StackFailureKind } from "./FailureKind.ts"; import { LifecycleEvent } from "./Lifecycle.ts"; /** The execution step a service is performing right now, for status reporting. */ @@ -32,6 +33,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; }> {} /** The exact runtime resources owned by one service launch. */ diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 8ac184fac6..fc76077847 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -268,9 +268,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); }), diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index 51eb4d2e39..231e470262 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -110,6 +110,7 @@ const shutdownFailure = (cause: unknown): ShutdownFailure => { return { message: failure.message, ...(failure.outcomes === undefined ? {} : { outcomes: failure.outcomes }), + ...(failure.kind === undefined ? {} : { kind: failure.kind }), }; }; diff --git a/packages/stack/src/destroy.runtime-unavailable.integration.test.ts b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts index 8db341a4e5..5576dd6ce0 100644 --- a/packages/stack/src/destroy.runtime-unavailable.integration.test.ts +++ b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts @@ -173,6 +173,7 @@ it.live("keeps a stack registered when its container engine rejects the listing" // The socket itself refuses the owner's own startup, resolving its pinned engine target: // the owner never starts, so the stack stays registered for a later retry. 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)), @@ -215,6 +216,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 7cb89e2188..02b6d734bb 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -40,6 +40,7 @@ import { type SupabaseCompositionOptions, } from "./composition/Supabase.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 StackNamespace from "./StackNamespace.ts"; import { engineUnreachable, resolveEngineTarget } from "./runtime/Container.ts"; @@ -68,6 +69,7 @@ export { resolveNativePostgresUser } from "./runtime/postgres-user.ts"; export { apiRoute, sharesApiEndpoint } from "./host/Endpoints.ts"; export { gatewayLog }; 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; @@ -124,22 +126,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 = @@ -524,7 +533,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 } : {}), }), ), }), @@ -772,7 +784,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)); @@ -865,7 +877,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", "already-exists"); // 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, { @@ -879,7 +891,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", "already-exists"); yield* state.save(saved); }), ); @@ -893,7 +905,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 @@ -1018,13 +1030,16 @@ export const findDeleted = Effect.fn("Stack.findDeleted")( const [unlisted] = probes.flatMap(({ engine, outcome }) => Result.isFailure(outcome) ? [ - `Unable to list ${engineLabel(engine)} containers while looking for stack ${options.id}'s leftovers: ${failureMessage(outcome.failure)}`, + { + message: `Unable to list ${engineLabel(engine)} containers while looking for stack ${options.id}'s leftovers: ${failureMessage(outcome.failure)}`, + kind: failureKind(outcome.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 { target } of matched) { @@ -1037,16 +1052,23 @@ export const findDeleted = Effect.fn("Stack.findDeleted")( }), ).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(({ target }) => engineLabel(target.engine)).join(" and ")} containers stack ${options.id} left behind. ${unlisted}`, + `Removed the ${matched.map(({ target }) => engineLabel(target.engine)).join(" and ")} containers stack ${options.id} left behind. ${unlisted.message}`, + unlisted.kind, ); }).pipe( Effect.mapError((cause) => failure("destroy", cause)), diff --git a/packages/stack/src/internal/host-process.ts b/packages/stack/src/internal/host-process.ts index 395218ffea..fe127190c7 100644 --- a/packages/stack/src/internal/host-process.ts +++ b/packages/stack/src/internal/host-process.ts @@ -1,6 +1,7 @@ import { Cause, Duration, Effect, Exit, identity, 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 "../StackNamespace.ts"; import { RegistrationCheckInterval, @@ -87,10 +88,12 @@ const program = ( yield* runStackHost({ ...host, ...overrides }).pipe( Effect.catchCause((cause) => { 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 da4cb5f163..8948fdc1a8 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -34,6 +34,7 @@ import { type ContainerProcess, type EngineTarget, } from "./Container.ts"; +import { failureKind } from "../FailureKind.ts"; import { engineTarget, testEngine } from "../../tests/engine-target.ts"; import * as Owner from "../Owner.ts"; import * as StackNamespace from "../StackNamespace.ts"; @@ -90,6 +91,34 @@ describe("container process adapter", { timeout: 120_000 }, () => { }).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* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -108,7 +137,10 @@ describe("container process adapter", { timeout: 120_000 }, () => { ); 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)), ); @@ -789,6 +821,48 @@ describe("container process adapter", { timeout: 120_000 }, () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("classifies a container start the engine could not publish as port allocation", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeContainerRuntime({ + target: containerTarget, + root: ".", + hostGateway: sharedHostGateway, + }); + yield* runtime.prepare(image); + const result = yield* makeContainerRuntime({ + target: containerTarget, + root: ".", + hostGateway: sharedHostGateway, + }).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("publish failure 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* () { @@ -1658,9 +1732,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 ( @@ -1673,14 +1751,35 @@ const makePullFailureSpawner = ( return yield* delegate.spawn( ChildProcess.make( process.execPath, - ["-e", "console.error('pull rejected by cached-image regression'); process.exit(73)"], - { stdin: "ignore" }, + ["-e", "console.error(process.env.PULL_STDERR); process.exit(73)"], + { stdin: "ignore", env: { ...process.env, PULL_STDERR: stderr } }, ), ); }); return delegate.spawn(command); }); +/** 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 === testEngine && + 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 d64168089d..5ac71eb26b 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -21,6 +21,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 { CONTAINER_ENV_DIRNAME } from "../namespace/Paths.ts"; import { identifyContainer } from "./ContainerName.ts"; @@ -29,6 +30,7 @@ export class ContainerError extends Data.TaggedError("ContainerError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; + readonly kind?: StackFailureKind; }> {} interface ContainerSpec { @@ -91,12 +93,43 @@ export interface ContainerRuntime { ) => Effect.Effect; } -const errorFor = (operation: string, cause: unknown): ContainerError => - new ContainerError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); +/** The engine picks published host ports (`127.0.0.1::port`), so a refusal is its own allocation. */ +const portAllocated = (message: string) => + /port is already allocated|address already in use/iu.test(message); + +/** A refused registry connection also reads "connection refused", so a pull needs the daemon named. */ +const pullEngineUnreachable = (cause: unknown, message: string) => + (cause instanceof Object && + "cause" in cause && + 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|connection refused)|unable to connect to podman socket:[^\n]*(?:the system cannot find the file specified|connection refused)/iu.test( + message, + ); + +const engineKind = ( + operation: string, + cause: unknown, + message: string, +): StackFailureKind | undefined => + cause instanceof ContainerError + ? cause.kind + : Cause.isTimeoutError(cause) + ? operation === "pull" + ? "image-pull" + : "engine-timeout" + : (operation === "pull" ? pullEngineUnreachable(cause, message) : engineUnreachable(cause)) + ? "engine-unavailable" + : portAllocated(message) + ? "port-allocation" + : undefined; + +const errorFor = (operation: string, cause: unknown): ContainerError => { + const message = cause instanceof Error ? cause.message : String(cause); + const kind = engineKind(operation, cause, message); + return new ContainerError({ operation, message, cause, ...(kind === undefined ? {} : { kind }) }); +}; /** Labels containers this run creates, when `SUPABASE_STACK_TEST_RUN` is set. */ const testRunLabelArgs = readTestRunLabelArgs.pipe( @@ -829,10 +862,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", + }); }), ), ), 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 f718783deb..fd48fbf0fa 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -353,7 +353,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)), ); @@ -516,7 +517,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 af855f6e30..0fb5621fda 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -267,6 +267,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 }), ), @@ -280,6 +281,7 @@ const startupFailure = ( serviceError( "launch", withRecentOutput(`${service} startup exited with ${result.code}`, result.output), + "process-exit", ); const isAddressInUse = (line: string) => @@ -699,6 +701,7 @@ export const makeProcessRecipe = : `${context.config.service} native listener bind was not confirmed`, yield* recentOutput(attempt), ), + "health-timeout", ); } @@ -715,6 +718,7 @@ export const makeProcessRecipe = const failure = serviceError( "launch", withRecentOutput(exitMessage, yield* recentOutput(attempt)), + "process-exit", ); const collided = Exit.isSuccess(observed.result) && @@ -736,6 +740,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 f9f3d7a8a6..3f322449d1 100644 --- a/packages/stack/src/shutdown.integration.test.ts +++ b/packages/stack/src/shutdown.integration.test.ts @@ -123,6 +123,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)),