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);