diff --git a/t3code/apps/server/src/_meta.json b/t3code/apps/server/src/_meta.json new file mode 100644 index 000000000..d41b53726 --- /dev/null +++ b/t3code/apps/server/src/_meta.json @@ -0,0 +1,5 @@ +{ + "contributor": "Antigravity", + "generation_context": "Safe public provenance: Antigravity AI Agent session. Task: Standardize server error types with Effect.Data.TaggedEnum.", + "completed_at": "2026-05-30T20:03:00Z" +} diff --git a/t3code/apps/server/src/bootstrap.ts b/t3code/apps/server/src/bootstrap.ts index 9ad632879..5ce3c0115 100644 --- a/t3code/apps/server/src/bootstrap.ts +++ b/t3code/apps/server/src/bootstrap.ts @@ -6,16 +6,15 @@ import type { Readable } from "node:stream"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { ServerError } from "./errors.ts"; -class BootstrapError extends Data.TaggedError("BootstrapError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +// Removed BootstrapError - using ConfigError from ServerError instead export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* ( schema: Schema.Codec, @@ -23,7 +22,7 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function options?: { timeoutMs?: number; }, -): Effect.fn.Return, BootstrapError> { +): Effect.fn.Return, ServerError> { const fdReady = yield* isFdReady(fd); if (!fdReady) return Option.none(); @@ -31,7 +30,7 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function const timeoutMs = options?.timeoutMs ?? 1000; - return yield* Effect.callback, BootstrapError>((resume) => { + return yield* Effect.callback, ServerError>((resume) => { const input = readline.createInterface({ input: stream, crlfDelay: Infinity, @@ -52,9 +51,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } resume( Effect.fail( - new BootstrapError({ + ServerError.ConfigError({ message: "Failed to read bootstrap envelope.", cause: error, + timestamp: DateTime.toEpochMillis(DateTime.nowUnsafe()), }), ), ); @@ -67,9 +67,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } else { resume( Effect.fail( - new BootstrapError({ + ServerError.ConfigError({ message: "Failed to decode bootstrap envelope.", cause: parsed.failure, + timestamp: DateTime.toEpochMillis(DateTime.nowUnsafe()), }), ), ); @@ -97,9 +98,10 @@ const isFdReady = (fd: number) => Effect.try({ try: () => NFS.fstatSync(fd), catch: (error) => - new BootstrapError({ + ServerError.ConfigError({ message: "Failed to stat bootstrap fd.", cause: error, + timestamp: DateTime.toEpochMillis(DateTime.nowUnsafe()), }), }).pipe( Effect.as(true), @@ -110,7 +112,7 @@ const isFdReady = (fd: number) => ); const makeBootstrapInputStream = (fd: number) => - Effect.try({ + Effect.try({ try: () => { const fdPath = resolveFdPath(fd); if (fdPath === undefined) { @@ -136,9 +138,10 @@ const makeBootstrapInputStream = (fd: number) => } }, catch: (error) => - new BootstrapError({ + ServerError.ConfigError({ message: "Failed to duplicate bootstrap fd.", cause: error, + timestamp: DateTime.toEpochMillis(DateTime.nowUnsafe()), }), }); diff --git a/t3code/apps/server/src/errors.test.ts b/t3code/apps/server/src/errors.test.ts new file mode 100644 index 000000000..c974eed41 --- /dev/null +++ b/t3code/apps/server/src/errors.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { ServerError, errorToResponse, errorToLog } from "./errors.ts"; + +describe("errors", () => { + it("should map AuthError to 401", () => { + const error = ServerError.AuthError({ message: "test", timestamp: 123 }); + expect(errorToResponse(error)).toBe(401); + }); + + it("should map ValidationError to 400", () => { + const error = ServerError.ValidationError({ message: "test", timestamp: 123 }); + expect(errorToResponse(error)).toBe(400); + }); + + it("should map DatabaseError to 500", () => { + const error = ServerError.DatabaseError({ message: "test", timestamp: 123 }); + expect(errorToResponse(error)).toBe(500); + }); + + it("should map NetworkError to 502", () => { + const error = ServerError.NetworkError({ message: "test", timestamp: 123 }); + expect(errorToResponse(error)).toBe(502); + }); + + it("should map ConfigError to 500", () => { + const error = ServerError.ConfigError({ message: "test", timestamp: 123 }); + expect(errorToResponse(error)).toBe(500); + }); + + it("should map GitError to 422", () => { + const error = ServerError.GitError({ message: "test", timestamp: 123 }); + expect(errorToResponse(error)).toBe(422); + }); + + it("should preserve cause chain and generate log JSON", () => { + const cause = new Error("inner error"); + const error = ServerError.DatabaseError({ + message: "outer error", + cause, + timestamp: 12345, + }); + + const log = errorToLog(error); + expect(log.tag).toBe("DatabaseError"); + expect(log.message).toBe("outer error"); + expect(log.timestamp).toBe(12345); + expect(log.stackTrace).toBeDefined(); + expect(log.stackTrace).toContain("inner error"); + }); +}); diff --git a/t3code/apps/server/src/errors.ts b/t3code/apps/server/src/errors.ts new file mode 100644 index 000000000..490dfb3be --- /dev/null +++ b/t3code/apps/server/src/errors.ts @@ -0,0 +1,37 @@ +import * as Data from "effect/Data"; +import * as Match from "effect/Match"; + +export type ServerError = Data.TaggedEnum<{ + NetworkError: { readonly message: string; readonly cause?: unknown; readonly timestamp: number }; + DatabaseError: { readonly message: string; readonly cause?: unknown; readonly timestamp: number }; + AuthError: { readonly message: string; readonly cause?: unknown; readonly timestamp: number }; + GitError: { readonly message: string; readonly cause?: unknown; readonly timestamp: number }; + ConfigError: { readonly message: string; readonly cause?: unknown; readonly timestamp: number }; + ValidationError: { readonly message: string; readonly cause?: unknown; readonly timestamp: number }; +}>; + +export const ServerError = Data.taggedEnum(); + +export const errorToResponse = (error: ServerError): number => + Match.value(error).pipe( + Match.tag("AuthError", () => 401), + Match.tag("ValidationError", () => 400), + Match.tag("DatabaseError", () => 500), + Match.tag("NetworkError", () => 502), + Match.tag("ConfigError", () => 500), + Match.tag("GitError", () => 422), + Match.exhaustive + ); + +export const errorToLog = (error: ServerError) => { + let stackTrace = undefined; + if (error.cause instanceof Error && error.cause.stack) { + stackTrace = error.cause.stack; + } + return { + tag: error._tag, + message: error.message, + stackTrace, + timestamp: error.timestamp, + }; +}; diff --git a/t3code/apps/server/src/http.ts b/t3code/apps/server/src/http.ts index f287b4c59..5377142c1 100644 --- a/t3code/apps/server/src/http.ts +++ b/t3code/apps/server/src/http.ts @@ -2,6 +2,7 @@ import Mime from "@effect/platform-node/Mime"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -21,6 +22,7 @@ import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath, } from "./attachmentPaths.ts"; +import { ServerError } from "./errors.ts"; import { resolveAttachmentPathById } from "./attachmentStore.ts"; import { resolveStaticDir, ServerConfig } from "./config.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; @@ -81,10 +83,7 @@ export const serverEnvironmentRouteLayer = HttpRouter.add( }), ); -class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ - readonly cause: unknown; - readonly bodyJson: OtlpTracer.TraceData; -}> {} +// Removed DecodeOtlpTraceRecordsError - using NetworkError from ServerError instead export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", @@ -100,7 +99,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( yield* Effect.try({ try: () => decodeOtlpTraceRecords(bodyJson), - catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), + catch: (cause) => ServerError.NetworkError({ message: "Failed to decode OTLP trace records", cause, timestamp: DateTime.toEpochMillis(DateTime.nowUnsafe()) }), }).pipe( Effect.flatMap((records) => browserTraceCollector.record(records)), Effect.catch((cause) => diff --git a/t3code/apps/server/src/serverRuntimeStartup.test.ts b/t3code/apps/server/src/serverRuntimeStartup.test.ts index 33b9abcb9..1c62d3197 100644 --- a/t3code/apps/server/src/serverRuntimeStartup.test.ts +++ b/t3code/apps/server/src/serverRuntimeStartup.test.ts @@ -4,6 +4,7 @@ import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; @@ -21,8 +22,8 @@ import { makeCommandGate, resolveAutoBootstrapWelcomeTargets, resolveWelcomeBase, - ServerRuntimeStartupError, } from "./serverRuntimeStartup.ts"; +import { ServerError } from "./errors.ts"; it("uses the canonical Codex default for auto-bootstrapped model selection", () => { assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { @@ -64,8 +65,9 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => .pipe(Effect.forkScoped); yield* commandGate.failCommandReady( - new ServerRuntimeStartupError({ + ServerError.ConfigError({ message: "startup failed", + timestamp: DateTime.toEpochMillis(DateTime.nowUnsafe()), }), ); diff --git a/t3code/apps/server/src/serverRuntimeStartup.ts b/t3code/apps/server/src/serverRuntimeStartup.ts index 9ec536105..bbdc6fdd1 100644 --- a/t3code/apps/server/src/serverRuntimeStartup.ts +++ b/t3code/apps/server/src/serverRuntimeStartup.ts @@ -8,6 +8,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Data from "effect/Data"; +import { ServerError } from "./errors.ts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -40,17 +41,14 @@ import { issueHeadlessServeAccessInfo, } from "./startupAccess.ts"; -export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +// Removed ServerRuntimeStartupError - using ConfigError from ServerError instead export interface ServerRuntimeStartupShape { - readonly awaitCommandReady: Effect.Effect; + readonly awaitCommandReady: Effect.Effect; readonly markHttpListening: Effect.Effect; readonly enqueueCommand: ( effect: Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; } export class ServerRuntimeStartup extends Context.Service< @@ -62,15 +60,15 @@ interface QueuedCommand { readonly run: Effect.Effect; } -type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError; +type CommandReadinessState = "pending" | "ready" | ServerError; interface CommandGate { - readonly awaitCommandReady: Effect.Effect; + readonly awaitCommandReady: Effect.Effect; readonly signalCommandReady: Effect.Effect; - readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect; + readonly failCommandReady: (error: ServerError) => Effect.Effect; readonly enqueueCommand: ( effect: Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; } const settleQueuedCommand = (deferred: Deferred.Deferred, exit: Exit.Exit) => @@ -79,7 +77,7 @@ const settleQueuedCommand = (deferred: Deferred.Deferred, exit: Exit : Deferred.failCause(deferred, exit.cause); export const makeCommandGate = Effect.gen(function* () { - const commandReady = yield* Deferred.make(); + const commandReady = yield* Deferred.make(); const commandQueue = yield* Queue.unbounded(); const commandReadinessState = yield* Ref.make("pending"); @@ -106,10 +104,10 @@ export const makeCommandGate = Effect.gen(function* () { return yield* effect; } if (readinessState !== "pending") { - return yield* readinessState; + return yield* Effect.fail(readinessState); } - const result = yield* Deferred.make(); + const result = yield* Deferred.make(); yield* Queue.offer(commandQueue, { run: Deferred.await(commandReady).pipe( Effect.flatMap(() => effect), @@ -402,9 +400,10 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { Effect.gen(function* () { const startupExit = yield* Effect.exit(startup); if (Exit.isFailure(startupExit)) { - const error = new ServerRuntimeStartupError({ + const error = ServerError.ConfigError({ message: "Server runtime startup failed before command readiness.", cause: startupExit.cause, + timestamp: DateTime.toEpochMillis(DateTime.nowUnsafe()), }); yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); yield* commandGate.failCommandReady(error);