Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions t3code/apps/server/src/_meta.json
Original file line number Diff line number Diff line change
@@ -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"
}
25 changes: 14 additions & 11 deletions t3code/apps/server/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,31 @@ 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* <A, I>(
schema: Schema.Codec<A, I>,
fd: number,
options?: {
timeoutMs?: number;
},
): Effect.fn.Return<Option.Option<A>, BootstrapError> {
): Effect.fn.Return<Option.Option<A>, ServerError> {
const fdReady = yield* isFdReady(fd);
if (!fdReady) return Option.none();

const stream = yield* makeBootstrapInputStream(fd);

const timeoutMs = options?.timeoutMs ?? 1000;

return yield* Effect.callback<Option.Option<A>, BootstrapError>((resume) => {
return yield* Effect.callback<Option.Option<A>, ServerError>((resume) => {
const input = readline.createInterface({
input: stream,
crlfDelay: Infinity,
Expand All @@ -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()),
}),
),
);
Expand All @@ -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()),
}),
),
);
Expand Down Expand Up @@ -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),
Expand All @@ -110,7 +112,7 @@ const isFdReady = (fd: number) =>
);

const makeBootstrapInputStream = (fd: number) =>
Effect.try<Readable, BootstrapError>({
Effect.try<Readable, ServerError>({
try: () => {
const fdPath = resolveFdPath(fd);
if (fdPath === undefined) {
Expand All @@ -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()),
}),
});

Expand Down
50 changes: 50 additions & 0 deletions t3code/apps/server/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
37 changes: 37 additions & 0 deletions t3code/apps/server/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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<ServerError>();

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,
};
};
9 changes: 4 additions & 5 deletions t3code/apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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) =>
Expand Down
6 changes: 4 additions & 2 deletions t3code/apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(), {
Expand Down Expand Up @@ -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()),
}),
);

Expand Down
27 changes: 13 additions & 14 deletions t3code/apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void, ServerRuntimeStartupError>;
readonly awaitCommandReady: Effect.Effect<void, ServerError>;
readonly markHttpListening: Effect.Effect<void>;
readonly enqueueCommand: <A, E>(
effect: Effect.Effect<A, E>,
) => Effect.Effect<A, E | ServerRuntimeStartupError>;
) => Effect.Effect<A, E | ServerError>;
}

export class ServerRuntimeStartup extends Context.Service<
Expand All @@ -62,15 +60,15 @@ interface QueuedCommand {
readonly run: Effect.Effect<void, never>;
}

type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError;
type CommandReadinessState = "pending" | "ready" | ServerError;

interface CommandGate {
readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>;
readonly awaitCommandReady: Effect.Effect<void, ServerError>;
readonly signalCommandReady: Effect.Effect<void>;
readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect<void>;
readonly failCommandReady: (error: ServerError) => Effect.Effect<void>;
readonly enqueueCommand: <A, E>(
effect: Effect.Effect<A, E>,
) => Effect.Effect<A, E | ServerRuntimeStartupError>;
) => Effect.Effect<A, E | ServerError>;
}

const settleQueuedCommand = <A, E>(deferred: Deferred.Deferred<A, E>, exit: Exit.Exit<A, E>) =>
Expand All @@ -79,7 +77,7 @@ const settleQueuedCommand = <A, E>(deferred: Deferred.Deferred<A, E>, exit: Exit
: Deferred.failCause(deferred, exit.cause);

export const makeCommandGate = Effect.gen(function* () {
const commandReady = yield* Deferred.make<void, ServerRuntimeStartupError>();
const commandReady = yield* Deferred.make<void, ServerError>();
const commandQueue = yield* Queue.unbounded<QueuedCommand>();
const commandReadinessState = yield* Ref.make<CommandReadinessState>("pending");

Expand All @@ -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<A, E | ServerRuntimeStartupError>();
const result = yield* Deferred.make<A, E | ServerError>();
yield* Queue.offer(commandQueue, {
run: Deferred.await(commandReady).pipe(
Effect.flatMap(() => effect),
Expand Down Expand Up @@ -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);
Expand Down
Loading