| author | |
| committer | |
| log | 00cdf418996227f49ec2b45fde180a5230cb99e2 |
| tree | 79284811c880383163e71deddb14aaa8765e3d98 |
| parent | 1b98213d6d93df663b91774a5f18e94a9a1906af |
- 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 flows7 files changed, 121 insertions(+), 56 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+52-27| ... | ... | @@ -9,7 +9,7 @@ import { |
| 9 | 9 | type OrchestrationEvent, |
| 10 | 10 | type OrchestrationThread, |
| 11 | 11 | } 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"; | |
| 13 | 13 | |
| 14 | 14 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; |
| 15 | 15 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; |
| ... | ... | @@ -75,20 +75,24 @@ export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): st |
| 75 | 75 | return runGit(cwd, ["show", `${ref}:${filePath}`]); |
| 76 | 76 | } |
| 77 | 77 | |
| 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>, | |
| 80 | 84 | predicate: (value: A) => boolean, |
| 81 | 85 | description: string, |
| 82 | 86 | timeoutMs?: number, |
| 83 | 87 | ): 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>, | |
| 86 | 90 | predicate: (value: A) => value is B, |
| 87 | 91 | description: string, |
| 88 | 92 | timeoutMs?: number, |
| 89 | 93 | ): 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>, | |
| 92 | 96 | predicate: (value: A) => boolean, |
| 93 | 97 | description: string, |
| 94 | 98 | timeoutMs = 3000, |
| ... | ... | @@ -106,12 +110,26 @@ function waitFor<A>( |
| 106 | 110 | while: (error) => error === RETRY_SIGNAL, |
| 107 | 111 | }), |
| 108 | 112 | Effect.mapError((error) => |
| 109 | error === RETRY_SIGNAL ? new Error(`Timed out waiting for ${description}.`) : error, | |
| 113 | error === RETRY_SIGNAL ? new WaitForTimeoutError({ description }) : error, | |
| 110 | 114 | ), |
| 111 | 115 | Effect.orDie, |
| 112 | 116 | ); |
| 113 | 117 | } |
| 114 | 118 | |
| 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 | ||
| 115 | 133 | export interface OrchestrationIntegrationHarness { |
| 116 | 134 | readonly rootDir: string; |
| 117 | 135 | readonly workspaceDir: string; |
| ... | ... | @@ -153,7 +171,10 @@ export interface OrchestrationIntegrationHarness { |
| 153 | 171 | |
| 154 | 172 | export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { |
| 155 | 173 | 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); | |
| 157 | 178 | const adapterHarness = yield* makeTestProviderAdapterHarness; |
| 158 | 179 | |
| 159 | 180 | const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); |
| ... | ... | @@ -215,30 +236,34 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { |
| 215 | 236 | ); |
| 216 | 237 | |
| 217 | 238 | const runtime = ManagedRuntime.make(layer); |
| 218 | const engine = yield* Effect.promise(() => | |
| 239 | const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () => | |
| 219 | 240 | runtime.runPromise(Effect.service(OrchestrationEngineService)), |
| 220 | ); | |
| 221 | const reactor = yield* Effect.promise(() => | |
| 241 | ).pipe(Effect.orDie); | |
| 242 | const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => | |
| 222 | 243 | runtime.runPromise(Effect.service(OrchestrationReactor)), |
| 223 | ); | |
| 224 | const snapshotQuery = yield* Effect.promise(() => | |
| 244 | ).pipe(Effect.orDie); | |
| 245 | const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => | |
| 225 | 246 | runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), |
| 226 | ); | |
| 227 | const providerService = yield* Effect.promise(() => | |
| 247 | ).pipe(Effect.orDie); | |
| 248 | const providerService = yield* tryRuntimePromise("load ProviderService service", () => | |
| 228 | 249 | runtime.runPromise(Effect.service(ProviderService)), |
| 229 | ); | |
| 230 | const checkpointStore = yield* Effect.promise(() => | |
| 250 | ).pipe(Effect.orDie); | |
| 251 | const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => | |
| 231 | 252 | 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); | |
| 239 | 262 | |
| 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); | |
| 242 | 267 | yield* sleep(10); |
| 243 | 268 | |
| 244 | 269 | const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( |
apps/server/integration/orchestrationEngine.integration.test.ts+20-3| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | ThreadId, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import { assert, it } from "@effect/vitest"; |
| 16 | import { Effect, Exit, Option } from "effect"; | |
| 16 | import { Effect, Exit, Option, Schema } from "effect"; | |
| 17 | 17 | |
| 18 | 18 | import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; |
| 19 | 19 | import { |
| ... | ... | @@ -45,8 +45,25 @@ function nowIso() { |
| 45 | 45 | return new Date().toISOString(); |
| 46 | 46 | } |
| 47 | 47 | |
| 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 | ||
| 48 | 62 | 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); | |
| 50 | 67 | |
| 51 | 68 | function waitForSync<A>( |
| 52 | 69 | read: () => A, |
| ... | ... | @@ -63,7 +80,7 @@ function waitForSync<A>( |
| 63 | 80 | return value; |
| 64 | 81 | } |
| 65 | 82 | if (Date.now() >= deadline) { |
| 66 | return yield* Effect.die(new Error(`Timed out waiting for ${description}.`)); | |
| 83 | return yield* Effect.die(new IntegrationWaitTimeoutError({ description })); | |
| 67 | 84 | } |
| 68 | 85 | yield* sleep(10); |
| 69 | 86 | } |
apps/server/src/git/Layers/GitManager.test.ts+20-15| ... | ... | @@ -3,10 +3,10 @@ import path from "node:path"; |
| 3 | 3 | |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | 5 | import { it } from "@effect/vitest"; |
| 6 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | |
| 6 | import { Effect, FileSystem, Layer, PlatformError, Schema, Scope } from "effect"; | |
| 7 | 7 | import { expect } from "vitest"; |
| 8 | 8 | |
| 9 | import { GitHubCliError, TextGenerationError } from "../Errors.ts"; | |
| 9 | import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; | |
| 10 | 10 | import { type GitManagerShape } from "../Services/GitManager.ts"; |
| 11 | 11 | import { |
| 12 | 12 | type GitHubCliShape, |
| ... | ... | @@ -19,11 +19,16 @@ import { GitService } from "../Services/GitService.ts"; |
| 19 | 19 | import { GitCoreLive } from "./GitCore.ts"; |
| 20 | 20 | import { makeGitManager } from "./GitManager.ts"; |
| 21 | 21 | |
| 22 | class GitFixtureError extends Schema.TaggedErrorClass<GitFixtureError>()("GitFixtureError", { | |
| 23 | message: Schema.String, | |
| 24 | cause: Schema.optional(Schema.Defect), | |
| 25 | }) {} | |
| 26 | ||
| 22 | 27 | interface FakeGhScenario { |
| 23 | 28 | prListSequence?: string[]; |
| 24 | 29 | createdPrUrl?: string; |
| 25 | 30 | defaultBranch?: string; |
| 26 | failWith?: Error; | |
| 31 | failWith?: GitFixtureError; | |
| 27 | 32 | } |
| 28 | 33 | |
| 29 | 34 | interface FakeGitTextGeneration { |
| ... | ... | @@ -32,7 +37,7 @@ interface FakeGitTextGeneration { |
| 32 | 37 | branch: string | null; |
| 33 | 38 | stagedSummary: string; |
| 34 | 39 | stagedPatch: string; |
| 35 | }) => Effect.Effect<{ subject: string; body: string }, unknown>; | |
| 40 | }) => Effect.Effect<{ subject: string; body: string }, GitFixtureError>; | |
| 36 | 41 | generatePrContent: (input: { |
| 37 | 42 | cwd: string; |
| 38 | 43 | baseBranch: string; |
| ... | ... | @@ -40,7 +45,7 @@ interface FakeGitTextGeneration { |
| 40 | 45 | commitSummary: string; |
| 41 | 46 | diffSummary: string; |
| 42 | 47 | diffPatch: string; |
| 43 | }) => Effect.Effect<{ title: string; body: string }, unknown>; | |
| 48 | }) => Effect.Effect<{ title: string; body: string }, GitFixtureError>; | |
| 44 | 49 | } |
| 45 | 50 | |
| 46 | 51 | function makeTempDir( |
| ... | ... | @@ -58,7 +63,7 @@ function runGit( |
| 58 | 63 | allowNonZeroExit = false, |
| 59 | 64 | ): Effect.Effect< |
| 60 | 65 | { readonly code: number; readonly stdout: string; readonly stderr: string }, |
| 61 | unknown, | |
| 66 | GitCommandError, | |
| 62 | 67 | GitService |
| 63 | 68 | > { |
| 64 | 69 | return Effect.gen(function* () { |
| ... | ... | @@ -74,7 +79,7 @@ function runGit( |
| 74 | 79 | |
| 75 | 80 | function initRepo( |
| 76 | 81 | cwd: string, |
| 77 | ): Effect.Effect<void, unknown, FileSystem.FileSystem | Scope.Scope | GitService> { | |
| 82 | ): Effect.Effect<void, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitService> { | |
| 78 | 83 | return Effect.gen(function* () { |
| 79 | 84 | const fs = yield* FileSystem.FileSystem; |
| 80 | 85 | yield* runGit(cwd, ["init", "--initial-branch=main"]); |
| ... | ... | @@ -88,7 +93,7 @@ function initRepo( |
| 88 | 93 | |
| 89 | 94 | function createBareRemote(): Effect.Effect< |
| 90 | 95 | string, |
| 91 | unknown, | |
| 96 | PlatformError.PlatformError | GitCommandError, | |
| 92 | 97 | FileSystem.FileSystem | Scope.Scope | GitService |
| 93 | 98 | > { |
| 94 | 99 | return Effect.gen(function* () { |
| ... | ... | @@ -139,7 +144,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T |
| 139 | 144 | }; |
| 140 | 145 | } |
| 141 | 146 | |
| 142 | const normalizeGhError = (error: Error): GitHubCliError => { | |
| 147 | const normalizeGhError = (error: GitFixtureError): GitHubCliError => { | |
| 143 | 148 | if (error.message.includes("Command not found: gh")) { |
| 144 | 149 | return new GitHubCliError({ |
| 145 | 150 | operation: "execute", |
| ... | ... | @@ -373,7 +378,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 373 | 378 | |
| 374 | 379 | const { manager } = yield* makeManager({ |
| 375 | 380 | ghScenario: { |
| 376 | failWith: new Error("Command not found: gh"), | |
| 381 | failWith: new GitFixtureError({ message: "Command not found: gh" }), | |
| 377 | 382 | }, |
| 378 | 383 | }); |
| 379 | 384 | |
| ... | ... | @@ -600,7 +605,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 600 | 605 | action: "commit_push", |
| 601 | 606 | }).pipe( |
| 602 | 607 | Effect.flip, |
| 603 | Effect.map((error) => (error instanceof Error ? error.message : String(error))), | |
| 608 | Effect.map((error) => error.message), | |
| 604 | 609 | ); |
| 605 | 610 | expect(errorMessage).toContain("detached HEAD"); |
| 606 | 611 | }), |
| ... | ... | @@ -617,7 +622,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 617 | 622 | |
| 618 | 623 | const { manager } = yield* makeManager({ |
| 619 | 624 | ghScenario: { |
| 620 | failWith: new Error("Command not found: gh"), | |
| 625 | failWith: new GitFixtureError({ message: "Command not found: gh" }), | |
| 621 | 626 | }, |
| 622 | 627 | }); |
| 623 | 628 | |
| ... | ... | @@ -626,7 +631,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 626 | 631 | action: "commit_push_pr", |
| 627 | 632 | }).pipe( |
| 628 | 633 | Effect.flip, |
| 629 | Effect.map((error) => (error instanceof Error ? error.message : String(error))), | |
| 634 | Effect.map((error) => error.message), | |
| 630 | 635 | ); |
| 631 | 636 | expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); |
| 632 | 637 | }), |
| ... | ... | @@ -643,7 +648,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 643 | 648 | |
| 644 | 649 | const { manager } = yield* makeManager({ |
| 645 | 650 | ghScenario: { |
| 646 | failWith: new Error("authentication failed"), | |
| 651 | failWith: new GitFixtureError({ message: "authentication failed" }), | |
| 647 | 652 | }, |
| 648 | 653 | }); |
| 649 | 654 | |
| ... | ... | @@ -652,7 +657,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 652 | 657 | action: "commit_push_pr", |
| 653 | 658 | }).pipe( |
| 654 | 659 | Effect.flip, |
| 655 | Effect.map((error) => (error instanceof Error ? error.message : String(error))), | |
| 660 | Effect.map((error) => error.message), | |
| 656 | 661 | ); |
| 657 | 662 | expect(errorMessage).toContain("gh auth login"); |
| 658 | 663 | }), |
apps/server/src/keybindings.test.ts+1-7| ... | ... | @@ -211,13 +211,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 211 | 211 | }); |
| 212 | 212 | |
| 213 | 213 | 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")); | |
| 221 | 215 | assert.deepEqual(configState.issues, [ |
| 222 | 216 | { |
| 223 | 217 | kind: "keybindings.invalid-entry", |
apps/server/src/open.ts+4-1| ... | ... | @@ -117,7 +117,10 @@ export const launchDetached = (launch: EditorLaunch) => |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | 119 | 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 | }); | |
| 121 | 124 | |
| 122 | 125 | return { |
| 123 | 126 | openBrowser: (target) => |
apps/server/src/terminal/Layers/NodePTY.ts+17-2| ... | ... | @@ -1,10 +1,18 @@ |
| 1 | 1 | import { createRequire } from "node:module"; |
| 2 | 2 | |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 3 | import { Effect, FileSystem, Layer, Path, Schema } from "effect"; | |
| 4 | 4 | import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; |
| 5 | 5 | |
| 6 | 6 | let didEnsureSpawnHelperExecutable = false; |
| 7 | 7 | |
| 8 | export class NodePtyAdapterError extends Schema.TaggedErrorClass<NodePtyAdapterError>()( | |
| 9 | "NodePtyAdapterError", | |
| 10 | { | |
| 11 | message: Schema.String, | |
| 12 | cause: Schema.optional(Schema.Defect), | |
| 13 | }, | |
| 14 | ) {} | |
| 15 | ||
| 8 | 16 | const resolveNodePtySpawnHelperPath = Effect.gen(function* () { |
| 9 | 17 | const requireForNodePty = createRequire(import.meta.url); |
| 10 | 18 | const path = yield* Path.Path; |
| ... | ... | @@ -90,7 +98,14 @@ export const NodePtyAdapterLive = Layer.effect( |
| 90 | 98 | const fs = yield* FileSystem.FileSystem; |
| 91 | 99 | const path = yield* Path.Path; |
| 92 | 100 | |
| 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 | }); | |
| 94 | 109 | |
| 95 | 110 | const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( |
| 96 | 111 | ensureNodePtySpawnHelperExecutable().pipe( |
apps/server/src/wsServer.ts+7-1| ... | ... | @@ -389,7 +389,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 389 | 389 | |
| 390 | 390 | case WS_METHODS.projectsSearchEntries: { |
| 391 | 391 | 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 | }); | |
| 393 | 399 | } |
| 394 | 400 | |
| 395 | 401 | case WS_METHODS.shellOpenInEditor: { |