History

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

Julius Marminge committed 8 months ago

Commit 00cdf411 parent 1b982137 files · +121 −56Browse files

Unified · Split

7 files
  1. apps/server/integration/OrchestrationEngineHarness.integration.ts +52 −27
  2. apps/server/integration/orchestrationEngine.integration.test.ts +20 −3
  3. apps/server/src/git/Layers/GitManager.test.ts +20 −15
  4. apps/server/src/keybindings.test.ts +1 −7
  5. apps/server/src/open.ts +4 −1
  6. apps/server/src/terminal/Layers/NodePTY.ts +17 −2
  7. apps/server/src/wsServer.ts +7 −1
apps/server/integration/OrchestrationEngineHarness.integration.ts+52−27
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 type OrchestrationEvent,
1010 type OrchestrationThread,
1111 } from "@t3tools/contracts";
12−import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect";
12+import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Schema, Scope, Stream } from "effect";
1313
1414 import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
1515 import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
@@ -75,20 +75,24 @@ export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): st · 59 unmodified lines
7575 return runGit(cwd, ["show", `${ref}:${filePath}`]);
7676 }
7777
78−function waitFor<A>(
79− read: Effect.Effect<A, unknown>,
78+class WaitForTimeoutError extends Schema.TaggedErrorClass<WaitForTimeoutError>()("WaitForTimeoutError", {
79+ description: Schema.String,
80+}) {}
81+
82+function 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>;
84−function waitFor<A, B extends A>(
85− read: Effect.Effect<A, unknown>,
88+function 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>;
90−function waitFor<A>(
91− read: Effect.Effect<A, unknown>,
94+function 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>( · 11 unmodified lines
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
119+class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass<OrchestrationHarnessRuntimeError>()(
120+ "OrchestrationHarnessRuntimeError",
121+ {
122+ operation: Schema.String,
123+ cause: Schema.optional(Schema.Defect),
124+ },
125+) {}
126+
127+const tryRuntimePromise = <A>(operation: string, run: () => Promise<A>) =>
128+ Effect.tryPromise({
129+ try: run,
130+ catch: (cause) => new OrchestrationHarnessRuntimeError({ operation, cause }),
131+ });
132+
115133 export interface OrchestrationIntegrationHarness {
116134 readonly rootDir: string;
117135 readonly workspaceDir: string;
@@ -153,7 +171,10 @@ export interface OrchestrationIntegrationHarness { · 35 unmodified lines
153171
154172 export 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* () { · 55 unmodified lines
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 { · 12 unmodified lines
1313 ThreadId,
1414 } from "@t3tools/contracts";
1515 import { assert, it } from "@effect/vitest";
16−import { Effect, Exit, Option } from "effect";
16+import { Effect, Exit, Option, Schema } from "effect";
1717
1818 import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
1919 import {
@@ -45,8 +45,25 @@ function nowIso() { · 25 unmodified lines
4545 return new Date().toISOString();
4646 }
4747
48+class IntegrationSleepError extends Schema.TaggedErrorClass<IntegrationSleepError>()(
49+ "IntegrationSleepError",
50+ {
51+ cause: Schema.optional(Schema.Defect),
52+ },
53+) {}
54+
55+class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass<IntegrationWaitTimeoutError>()(
56+ "IntegrationWaitTimeoutError",
57+ {
58+ description: Schema.String,
59+ },
60+) {}
61+
4862 const 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
5168 function waitForSync<A>(
5269 read: () => A,
@@ -63,7 +80,7 @@ function waitForSync<A>( · 10 unmodified lines
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"; · 2 unmodified lines
33
44 import * as NodeServices from "@effect/platform-node/NodeServices";
55 import { it } from "@effect/vitest";
6−import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
6+import { Effect, FileSystem, Layer, PlatformError, Schema, Scope } from "effect";
77 import { expect } from "vitest";
88
9−import { GitHubCliError, TextGenerationError } from "../Errors.ts";
9+import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
1010 import { type GitManagerShape } from "../Services/GitManager.ts";
1111 import {
1212 type GitHubCliShape,
@@ -19,11 +19,16 @@ import { GitService } from "../Services/GitService.ts"; · 6 unmodified lines
1919 import { GitCoreLive } from "./GitCore.ts";
2020 import { makeGitManager } from "./GitManager.ts";
2121
22+class GitFixtureError extends Schema.TaggedErrorClass<GitFixtureError>()("GitFixtureError", {
23+ message: Schema.String,
24+ cause: Schema.optional(Schema.Defect),
25+}) {}
26+
2227 interface FakeGhScenario {
2328 prListSequence?: string[];
2429 createdPrUrl?: string;
2530 defaultBranch?: string;
26− failWith?: Error;
31+ failWith?: GitFixtureError;
2732 }
2833
2934 interface FakeGitTextGeneration {
@@ -32,7 +37,7 @@ interface FakeGitTextGeneration { · 2 unmodified lines
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 { · 1 unmodified line
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
4651 function makeTempDir(
@@ -58,7 +63,7 @@ function runGit( · 11 unmodified lines
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( · 9 unmodified lines
7479
7580 function 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( · 7 unmodified lines
8893
8994 function 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 · 44 unmodified lines
139144 };
140145 }
141146
142−const normalizeGhError = (error: Error): GitHubCliError => {
147+const 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) => { · 227 unmodified lines
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) => { · 220 unmodified lines
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) => { · 10 unmodified lines
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) => { · 2 unmodified lines
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) => { · 10 unmodified lines
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) => { · 2 unmodified lines
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) => { · 210 unmodified lines
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) => · 116 unmodified lines
117117 });
118118
119119 const 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 @@
11 import { createRequire } from "node:module";
22
3−import { Effect, FileSystem, Layer, Path } from "effect";
3+import { Effect, FileSystem, Layer, Path, Schema } from "effect";
44 import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY";
55
66 let didEnsureSpawnHelperExecutable = false;
77
8+export class NodePtyAdapterError extends Schema.TaggedErrorClass<NodePtyAdapterError>()(
9+ "NodePtyAdapterError",
10+ {
11+ message: Schema.String,
12+ cause: Schema.optional(Schema.Defect),
13+ },
14+) {}
15+
816 const 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( · 79 unmodified lines
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< · 388 unmodified lines
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: {