authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 18:32:39-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 19:27:08-08:00
log00cdf418996227f49ec2b45fde180a5230cb99e2
tree79284811c880383163e71deddb14aaa8765e3d98
parent1b98213d6d93df663b91774a5f18e94a9a1906af

Harden server async error handling with typed Effect errors

- Replace raw `Effect.promise`/generic errors with `Effect.tryPromise` and tagged schema errors - Improve orchestration harness/test reliability with explicit timeout/runtime error types - Tighten related tests and error assertions to match typed error flows

7 files changed, 121 insertions(+), 56 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+52-27
......@@ -9,7 +9,7 @@ import {
99 type OrchestrationEvent,
1010 type OrchestrationThread,
1111} from "@t3tools/contracts";
12import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect";
12import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Schema, Scope, Stream } from "effect";
1313
1414import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
1515import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
......@@ -75,20 +75,24 @@ export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): st
7575 return runGit(cwd, ["show", `${ref}:${filePath}`]);
7676}
7777
78function waitFor<A>(
79 read: Effect.Effect<A, unknown>,
78class WaitForTimeoutError extends Schema.TaggedErrorClass<WaitForTimeoutError>()("WaitForTimeoutError", {
79 description: Schema.String,
80}) {}
81
82function waitFor<A, E>(
83 read: Effect.Effect<A, E>,
8084 predicate: (value: A) => boolean,
8185 description: string,
8286 timeoutMs?: number,
8387): Effect.Effect<A, never>;
84function waitFor<A, B extends A>(
85 read: Effect.Effect<A, unknown>,
88function waitFor<A, B extends A, E>(
89 read: Effect.Effect<A, E>,
8690 predicate: (value: A) => value is B,
8791 description: string,
8892 timeoutMs?: number,
8993): Effect.Effect<B, never>;
90function waitFor<A>(
91 read: Effect.Effect<A, unknown>,
94function waitFor<A, E>(
95 read: Effect.Effect<A, E>,
9296 predicate: (value: A) => boolean,
9397 description: string,
9498 timeoutMs = 3000,
......@@ -106,12 +110,26 @@ function waitFor<A>(
106110 while: (error) => error === RETRY_SIGNAL,
107111 }),
108112 Effect.mapError((error) =>
109 error === RETRY_SIGNAL ? new Error(`Timed out waiting for ${description}.`) : error,
113 error === RETRY_SIGNAL ? new WaitForTimeoutError({ description }) : error,
110114 ),
111115 Effect.orDie,
112116 );
113117}
114118
119class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass<OrchestrationHarnessRuntimeError>()(
120 "OrchestrationHarnessRuntimeError",
121 {
122 operation: Schema.String,
123 cause: Schema.optional(Schema.Defect),
124 },
125) {}
126
127const tryRuntimePromise = <A>(operation: string, run: () => Promise<A>) =>
128 Effect.tryPromise({
129 try: run,
130 catch: (cause) => new OrchestrationHarnessRuntimeError({ operation, cause }),
131 });
132
115133export interface OrchestrationIntegrationHarness {
116134 readonly rootDir: string;
117135 readonly workspaceDir: string;
......@@ -153,7 +171,10 @@ export interface OrchestrationIntegrationHarness {
153171
154172export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
155173 const sleep = (ms: number) =>
156 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
174 Effect.tryPromise({
175 try: () => new Promise<void>((resolve) => setTimeout(resolve, ms)),
176 catch: (cause) => new OrchestrationHarnessRuntimeError({ operation: "sleep", cause }),
177 }).pipe(Effect.orDie);
157178 const adapterHarness = yield* makeTestProviderAdapterHarness;
158179
159180 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
......@@ -215,30 +236,34 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
215236 );
216237
217238 const runtime = ManagedRuntime.make(layer);
218 const engine = yield* Effect.promise(() =>
239 const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () =>
219240 runtime.runPromise(Effect.service(OrchestrationEngineService)),
220 );
221 const reactor = yield* Effect.promise(() =>
241 ).pipe(Effect.orDie);
242 const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () =>
222243 runtime.runPromise(Effect.service(OrchestrationReactor)),
223 );
224 const snapshotQuery = yield* Effect.promise(() =>
244 ).pipe(Effect.orDie);
245 const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () =>
225246 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
226 );
227 const providerService = yield* Effect.promise(() =>
247 ).pipe(Effect.orDie);
248 const providerService = yield* tryRuntimePromise("load ProviderService service", () =>
228249 runtime.runPromise(Effect.service(ProviderService)),
229 );
230 const checkpointStore = yield* Effect.promise(() =>
250 ).pipe(Effect.orDie);
251 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>
231252 runtime.runPromise(Effect.service(CheckpointStore)),
232 );
233 const checkpointRepository = yield* Effect.promise(() =>
234 runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
235 );
236 const pendingApprovalRepository = yield* Effect.promise(() =>
237 runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
238 );
253 ).pipe(Effect.orDie);
254 const checkpointRepository = yield* tryRuntimePromise(
255 "load ProjectionCheckpointRepository service",
256 () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
257 ).pipe(Effect.orDie);
258 const pendingApprovalRepository = yield* tryRuntimePromise(
259 "load ProjectionPendingApprovalRepository service",
260 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
261 ).pipe(Effect.orDie);
239262
240 const scope = yield* Effect.promise(() => Effect.runPromise(Scope.make("sequential")));
241 yield* Effect.promise(() => runtime.runPromise(reactor.start.pipe(Scope.provide(scope))));
263 const scope = yield* Scope.make("sequential");
264 yield* tryRuntimePromise("start OrchestrationReactor", () =>
265 runtime.runPromise(reactor.start.pipe(Scope.provide(scope))),
266 ).pipe(Effect.orDie);
242267 yield* sleep(10);
243268
244269 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
apps/server/integration/orchestrationEngine.integration.test.ts+20-3
......@@ -13,7 +13,7 @@ import {
1313 ThreadId,
1414} from "@t3tools/contracts";
1515import { assert, it } from "@effect/vitest";
16import { Effect, Exit, Option } from "effect";
16import { Effect, Exit, Option, Schema } from "effect";
1717
1818import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
1919import {
......@@ -45,8 +45,25 @@ function nowIso() {
4545 return new Date().toISOString();
4646}
4747
48class IntegrationSleepError extends Schema.TaggedErrorClass<IntegrationSleepError>()(
49 "IntegrationSleepError",
50 {
51 cause: Schema.optional(Schema.Defect),
52 },
53) {}
54
55class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass<IntegrationWaitTimeoutError>()(
56 "IntegrationWaitTimeoutError",
57 {
58 description: Schema.String,
59 },
60) {}
61
4862const sleep = (ms: number) =>
49 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
63 Effect.tryPromise({
64 try: () => new Promise<void>((resolve) => setTimeout(resolve, ms)),
65 catch: (cause) => new IntegrationSleepError({ cause }),
66 }).pipe(Effect.orDie);
5067
5168function waitForSync<A>(
5269 read: () => A,
......@@ -63,7 +80,7 @@ function waitForSync<A>(
6380 return value;
6481 }
6582 if (Date.now() >= deadline) {
66 return yield* Effect.die(new Error(`Timed out waiting for ${description}.`));
83 return yield* Effect.die(new IntegrationWaitTimeoutError({ description }));
6784 }
6885 yield* sleep(10);
6986 }
apps/server/src/git/Layers/GitManager.test.ts+20-15
......@@ -3,10 +3,10 @@ import path from "node:path";
33
44import * as NodeServices from "@effect/platform-node/NodeServices";
55import { it } from "@effect/vitest";
6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
6import { Effect, FileSystem, Layer, PlatformError, Schema, Scope } from "effect";
77import { expect } from "vitest";
88
9import { GitHubCliError, TextGenerationError } from "../Errors.ts";
9import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
1010import { type GitManagerShape } from "../Services/GitManager.ts";
1111import {
1212 type GitHubCliShape,
......@@ -19,11 +19,16 @@ import { GitService } from "../Services/GitService.ts";
1919import { GitCoreLive } from "./GitCore.ts";
2020import { makeGitManager } from "./GitManager.ts";
2121
22class GitFixtureError extends Schema.TaggedErrorClass<GitFixtureError>()("GitFixtureError", {
23 message: Schema.String,
24 cause: Schema.optional(Schema.Defect),
25}) {}
26
2227interface FakeGhScenario {
2328 prListSequence?: string[];
2429 createdPrUrl?: string;
2530 defaultBranch?: string;
26 failWith?: Error;
31 failWith?: GitFixtureError;
2732}
2833
2934interface FakeGitTextGeneration {
......@@ -32,7 +37,7 @@ interface FakeGitTextGeneration {
3237 branch: string | null;
3338 stagedSummary: string;
3439 stagedPatch: string;
35 }) => Effect.Effect<{ subject: string; body: string }, unknown>;
40 }) => Effect.Effect<{ subject: string; body: string }, GitFixtureError>;
3641 generatePrContent: (input: {
3742 cwd: string;
3843 baseBranch: string;
......@@ -40,7 +45,7 @@ interface FakeGitTextGeneration {
4045 commitSummary: string;
4146 diffSummary: string;
4247 diffPatch: string;
43 }) => Effect.Effect<{ title: string; body: string }, unknown>;
48 }) => Effect.Effect<{ title: string; body: string }, GitFixtureError>;
4449}
4550
4651function makeTempDir(
......@@ -58,7 +63,7 @@ function runGit(
5863 allowNonZeroExit = false,
5964): Effect.Effect<
6065 { readonly code: number; readonly stdout: string; readonly stderr: string },
61 unknown,
66 GitCommandError,
6267 GitService
6368> {
6469 return Effect.gen(function* () {
......@@ -74,7 +79,7 @@ function runGit(
7479
7580function initRepo(
7681 cwd: string,
77): Effect.Effect<void, unknown, FileSystem.FileSystem | Scope.Scope | GitService> {
82): Effect.Effect<void, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitService> {
7883 return Effect.gen(function* () {
7984 const fs = yield* FileSystem.FileSystem;
8085 yield* runGit(cwd, ["init", "--initial-branch=main"]);
......@@ -88,7 +93,7 @@ function initRepo(
8893
8994function createBareRemote(): Effect.Effect<
9095 string,
91 unknown,
96 PlatformError.PlatformError | GitCommandError,
9297 FileSystem.FileSystem | Scope.Scope | GitService
9398> {
9499 return Effect.gen(function* () {
......@@ -139,7 +144,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
139144 };
140145}
141146
142const normalizeGhError = (error: Error): GitHubCliError => {
147const normalizeGhError = (error: GitFixtureError): GitHubCliError => {
143148 if (error.message.includes("Command not found: gh")) {
144149 return new GitHubCliError({
145150 operation: "execute",
......@@ -373,7 +378,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
373378
374379 const { manager } = yield* makeManager({
375380 ghScenario: {
376 failWith: new Error("Command not found: gh"),
381 failWith: new GitFixtureError({ message: "Command not found: gh" }),
377382 },
378383 });
379384
......@@ -600,7 +605,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
600605 action: "commit_push",
601606 }).pipe(
602607 Effect.flip,
603 Effect.map((error) => (error instanceof Error ? error.message : String(error))),
608 Effect.map((error) => error.message),
604609 );
605610 expect(errorMessage).toContain("detached HEAD");
606611 }),
......@@ -617,7 +622,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
617622
618623 const { manager } = yield* makeManager({
619624 ghScenario: {
620 failWith: new Error("Command not found: gh"),
625 failWith: new GitFixtureError({ message: "Command not found: gh" }),
621626 },
622627 });
623628
......@@ -626,7 +631,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
626631 action: "commit_push_pr",
627632 }).pipe(
628633 Effect.flip,
629 Effect.map((error) => (error instanceof Error ? error.message : String(error))),
634 Effect.map((error) => error.message),
630635 );
631636 expect(errorMessage).toContain("GitHub CLI (`gh`) is required");
632637 }),
......@@ -643,7 +648,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
643648
644649 const { manager } = yield* makeManager({
645650 ghScenario: {
646 failWith: new Error("authentication failed"),
651 failWith: new GitFixtureError({ message: "authentication failed" }),
647652 },
648653 });
649654
......@@ -652,7 +657,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
652657 action: "commit_push_pr",
653658 }).pipe(
654659 Effect.flip,
655 Effect.map((error) => (error instanceof Error ? error.message : String(error))),
660 Effect.map((error) => error.message),
656661 );
657662 expect(errorMessage).toContain("gh auth login");
658663 }),
apps/server/src/keybindings.test.ts+1-7
......@@ -211,13 +211,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
211211 });
212212
213213 assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle"));
214 assert.isFalse(
215 configState.keybindings.some(
216 (entry) =>
217 // @ts-expect-error - invalid command
218 entry.command === "invalid.command",
219 ),
220 );
214 assert.isFalse(configState.keybindings.some((entry) => String(entry.command) === "invalid.command"));
221215 assert.deepEqual(configState.issues, [
222216 {
223217 kind: "keybindings.invalid-entry",
apps/server/src/open.ts+4-1
......@@ -117,7 +117,10 @@ export const launchDetached = (launch: EditorLaunch) =>
117117 });
118118
119119const make = Effect.gen(function* () {
120 const open = yield* Effect.promise(() => import("open"));
120 const open = yield* Effect.tryPromise({
121 try: () => import("open"),
122 catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }),
123 });
121124
122125 return {
123126 openBrowser: (target) =>
apps/server/src/terminal/Layers/NodePTY.ts+17-2
......@@ -1,10 +1,18 @@
11import { createRequire } from "node:module";
22
3import { Effect, FileSystem, Layer, Path } from "effect";
3import { Effect, FileSystem, Layer, Path, Schema } from "effect";
44import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY";
55
66let didEnsureSpawnHelperExecutable = false;
77
8export class NodePtyAdapterError extends Schema.TaggedErrorClass<NodePtyAdapterError>()(
9 "NodePtyAdapterError",
10 {
11 message: Schema.String,
12 cause: Schema.optional(Schema.Defect),
13 },
14) {}
15
816const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
917 const requireForNodePty = createRequire(import.meta.url);
1018 const path = yield* Path.Path;
......@@ -90,7 +98,14 @@ export const NodePtyAdapterLive = Layer.effect(
9098 const fs = yield* FileSystem.FileSystem;
9199 const path = yield* Path.Path;
92100
93 const nodePty = yield* Effect.promise(() => import("node-pty"));
101 const nodePty = yield* Effect.tryPromise({
102 try: () => import("node-pty"),
103 catch: (cause) =>
104 new NodePtyAdapterError({
105 message: "failed to load node-pty module",
106 cause,
107 }),
108 });
94109
95110 const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached(
96111 ensureNodePtySpawnHelperExecutable().pipe(
apps/server/src/wsServer.ts+7-1
......@@ -389,7 +389,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
389389
390390 case WS_METHODS.projectsSearchEntries: {
391391 const body = stripRequestTag(request.body);
392 return yield* Effect.promise(() => searchWorkspaceEntries(body));
392 return yield* Effect.tryPromise({
393 try: () => searchWorkspaceEntries(body),
394 catch: (cause) =>
395 new RouteRequestError({
396 message: `Failed to search workspace entries: ${String(cause)}`,
397 }),
398 });
393399 }
394400
395401 case WS_METHODS.shellOpenInEditor: {