From c7b375e9b0f0581a977a2745e3afbf5732aecb15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:23:46 -0700 Subject: [PATCH 01/80] [codex] Structure Electron window failures (#3276) Co-authored-by: codex --- .../src/electron/ElectronWindow.test.ts | 204 ++++++++++++++- apps/desktop/src/electron/ElectronWindow.ts | 235 ++++++++++++++---- 2 files changed, 385 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index cc6c64842453..b59f8572739d 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -1,26 +1,39 @@ import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; -const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ - appFocusMock: vi.fn(), - getAllWindowsMock: vi.fn(), -})); +const { appFocusMock, browserWindowMock, getAllWindowsMock, getFocusedWindowMock } = vi.hoisted( + () => ({ + appFocusMock: vi.fn(), + browserWindowMock: vi.fn(function BrowserWindowMock() {}), + getAllWindowsMock: vi.fn(), + getFocusedWindowMock: vi.fn(), + }), +); vi.mock("electron", () => ({ app: { focus: appFocusMock, }, - BrowserWindow: { + BrowserWindow: Object.assign(browserWindowMock, { getAllWindows: getAllWindowsMock, - }, + getFocusedWindow: getFocusedWindowMock, + }), })); import * as ElectronWindow from "./ElectronWindow.ts"; -function makeBrowserWindow(input: { readonly destroyed: boolean }) { +const TestLayer = ElectronWindow.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + +function makeBrowserWindow(input: { readonly id: number; readonly destroyed: boolean }) { return { + id: input.id, isDestroyed: vi.fn(() => input.destroyed), } as unknown as Electron.BrowserWindow; } @@ -28,13 +41,78 @@ function makeBrowserWindow(input: { readonly destroyed: boolean }) { describe("ElectronWindow", () => { beforeEach(() => { appFocusMock.mockReset(); + browserWindowMock.mockReset(); getAllWindowsMock.mockReset(); + getFocusedWindowMock.mockReset(); }); + it.effect("preserves schema-safe creation context and the Electron cause", () => + Effect.gen(function* () { + const cause = new Error("native BrowserWindow construction failed"); + browserWindowMock.mockImplementationOnce(function BrowserWindowFailure() { + throw cause; + }); + const options = { + title: "T3 Code", + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + modal: false, + frame: true, + transparent: false, + backgroundColor: "#101010", + icon: {} as Electron.NativeImage, + webPreferences: { + preload: "/tmp/preload.js", + partition: "persist:t3code-preview-test", + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: true, + spellcheck: true, + }, + } satisfies Electron.BrowserWindowConstructorOptions; + const electronWindow = yield* ElectronWindow.ElectronWindow; + + const error = yield* electronWindow.create(options).pipe(Effect.flip); + + assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); + assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); + assert.deepEqual(error.options, { + title: "T3 Code", + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + modal: false, + frame: true, + transparent: false, + backgroundColor: "#101010", + webPreferences: { + preload: "/tmp/preload.js", + partition: "persist:t3code-preview-test", + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: true, + }, + }); + assert.isFalse("icon" in error.options); + assert.isFalse("spellcheck" in error.options.webPreferences); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to create Electron BrowserWindow "T3 Code" (1100x780).'); + assert.notInclude(error.message, cause.message); + assert.deepEqual(browserWindowMock.mock.calls, [[options]]); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("skips windows destroyed before appearance sync runs", () => Effect.gen(function* () { - const liveWindow = makeBrowserWindow({ destroyed: false }); - const destroyedWindow = makeBrowserWindow({ destroyed: true }); + const liveWindow = makeBrowserWindow({ id: 1, destroyed: false }); + const destroyedWindow = makeBrowserWindow({ id: 2, destroyed: true }); getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); const syncedWindows: Electron.BrowserWindow[] = []; @@ -46,6 +124,112 @@ describe("ElectronWindow", () => { ); assert.deepEqual(syncedWindows, [liveWindow]); - }).pipe(Effect.provide(ElectronWindow.layer)), + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves window enumeration failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("window enumeration failed"); + getAllWindowsMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.currentMainOrFirst); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "list-windows"); + assert.equal(error.platform, "linux"); + assert.isNull(error.windowId); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves reveal failures with the target window", () => + Effect.gen(function* () { + const cause = new Error("window restore failed"); + const window = { + id: 41, + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => true), + restore: vi.fn(() => { + throw cause; + }), + } as unknown as Electron.BrowserWindow; + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.reveal(window)); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "reveal-window"); + assert.equal(error.windowId, 41); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves message delivery failures with window and channel context", () => + Effect.gen(function* () { + const cause = new Error("renderer send failed"); + const window = { + id: 42, + isDestroyed: vi.fn(() => false), + webContents: { + send: vi.fn(() => { + throw cause; + }), + }, + } as unknown as Electron.BrowserWindow; + getAllWindowsMock.mockReturnValueOnce([window]); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.sendAll("desktop:update", { ready: true })); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "send-window-message"); + assert.equal(error.windowId, 42); + assert.equal(error.channel, "desktop:update"); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves destroy failures with the target window", () => + Effect.gen(function* () { + const cause = new Error("window destroy failed"); + const window = { + id: 43, + destroy: vi.fn(() => { + throw cause; + }), + } as unknown as Electron.BrowserWindow; + getAllWindowsMock.mockReturnValueOnce([window]); + + const electronWindow = yield* ElectronWindow.ElectronWindow; + const exit = yield* Effect.exit(electronWindow.destroyAll); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); + assert.equal(error.operation, "destroy-window"); + assert.equal(error.windowId, 43); + assert.isNull(error.channel); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 0bf98a9610ec..dacb2eebb47d 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -8,14 +8,69 @@ import * as Schema from "effect/Schema"; import * as Electron from "electron"; +const ElectronWindowCreateOptions = Schema.Struct({ + title: Schema.NullOr(Schema.String), + width: Schema.NullOr(Schema.Number), + height: Schema.NullOr(Schema.Number), + minWidth: Schema.NullOr(Schema.Number), + minHeight: Schema.NullOr(Schema.Number), + show: Schema.NullOr(Schema.Boolean), + modal: Schema.NullOr(Schema.Boolean), + frame: Schema.NullOr(Schema.Boolean), + transparent: Schema.NullOr(Schema.Boolean), + backgroundColor: Schema.NullOr(Schema.String), + webPreferences: Schema.Struct({ + preload: Schema.NullOr(Schema.String), + partition: Schema.NullOr(Schema.String), + sandbox: Schema.NullOr(Schema.Boolean), + contextIsolation: Schema.NullOr(Schema.Boolean), + nodeIntegration: Schema.NullOr(Schema.Boolean), + webviewTag: Schema.NullOr(Schema.Boolean), + }), +}); + +const ElectronWindowOperation = Schema.Literals([ + "list-windows", + "get-focused-window", + "inspect-window", + "reveal-window", + "send-window-message", + "destroy-window", +]); + export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowCreateError", { + options: ElectronWindowCreateOptions, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const title = this.options.title === null ? "" : ` "${this.options.title}"`; + const dimensions = + this.options.width === null || this.options.height === null + ? "" + : ` (${this.options.width}x${this.options.height})`; + return `Failed to create Electron BrowserWindow${title}${dimensions}.`; + } +} + +export const isElectronWindowCreateError = Schema.is(ElectronWindowCreateError); + +export class ElectronWindowOperationError extends Schema.TaggedErrorClass()( + "ElectronWindowOperationError", + { + operation: ElectronWindowOperation, + platform: Schema.String, + windowId: Schema.NullOr(Schema.Number), + channel: Schema.NullOr(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to create Electron BrowserWindow."; + const window = this.windowId === null ? "" : ` for window ${this.windowId}`; + const channel = this.channel === null ? "" : ` on channel ${JSON.stringify(this.channel)}`; + return `Electron window operation ${JSON.stringify(this.operation)} failed${window}${channel} on ${this.platform}.`; } } @@ -43,9 +98,38 @@ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const mainWindowRef = yield* Ref.make>(Option.none()); - const liveMain = Ref.get(mainWindowRef).pipe( - Effect.map(Option.filter((value) => !value.isDestroyed())), - ); + const listWindows = Effect.try({ + try: () => Electron.BrowserWindow.getAllWindows(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "list-windows", + platform, + windowId: null, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + + const isWindowDestroyed = (window: Electron.BrowserWindow) => + Effect.try({ + try: () => window.isDestroyed(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "inspect-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + + const liveMain = Effect.gen(function* () { + const main = yield* Ref.get(mainWindowRef); + if (Option.isNone(main) || (yield* isWindowDestroyed(main.value))) { + return Option.none(); + } + return main; + }); const currentMainOrFirst = Effect.gen(function* () { const main = yield* liveMain; @@ -53,27 +137,60 @@ export const make = Effect.gen(function* () { return main; } - return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( - Option.filter((window) => !window.isDestroyed()), - ); + const first = Option.fromNullishOr((yield* listWindows)[0] ?? null); + if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) { + return Option.none(); + } + return first; }); - const focusedMainOrFirst = Effect.sync(() => - Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( - Option.filter((window) => !window.isDestroyed()), - ), - ).pipe( - Effect.flatMap((focused) => - Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst, - ), - ); + const focusedMainOrFirst = Effect.gen(function* () { + const focused = yield* Effect.try({ + try: () => Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "get-focused-window", + platform, + windowId: null, + channel: null, + cause, + }), + }).pipe(Effect.orDie); + if (Option.isSome(focused) && !(yield* isWindowDestroyed(focused.value))) { + return focused; + } + return yield* currentMainOrFirst; + }); return ElectronWindow.of({ - create: (options) => - Effect.try({ + create: (options) => { + const webPreferences = options.webPreferences; + const diagnosticOptions = { + title: options.title ?? null, + width: options.width ?? null, + height: options.height ?? null, + minWidth: options.minWidth ?? null, + minHeight: options.minHeight ?? null, + show: options.show ?? null, + modal: options.modal ?? null, + frame: options.frame ?? null, + transparent: options.transparent ?? null, + backgroundColor: options.backgroundColor ?? null, + webPreferences: { + preload: webPreferences?.preload ?? null, + partition: webPreferences?.partition ?? null, + sandbox: webPreferences?.sandbox ?? null, + contextIsolation: webPreferences?.contextIsolation ?? null, + nodeIntegration: webPreferences?.nodeIntegration ?? null, + webviewTag: webPreferences?.webviewTag ?? null, + }, + } satisfies typeof ElectronWindowCreateOptions.Type; + + return Effect.try({ try: () => new Electron.BrowserWindow(options), - catch: (cause) => new ElectronWindowCreateError({ cause }), - }), + catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), + }); + }, main: liveMain, currentMainOrFirst, focusedMainOrFirst, @@ -89,45 +206,75 @@ export const make = Effect.gen(function* () { return Option.none(); }), reveal: (window) => - Effect.sync(() => { - if (window.isDestroyed()) { - return; - } + Effect.try({ + try: () => { + if (window.isDestroyed()) { + return; + } - if (window.isMinimized()) { - window.restore(); - } + if (window.isMinimized()) { + window.restore(); + } - if (!window.isVisible()) { - window.show(); - } + if (!window.isVisible()) { + window.show(); + } - if (platform === "darwin") { - Electron.app.focus({ steal: true }); - } + if (platform === "darwin") { + Electron.app.focus({ steal: true }); + } - window.focus(); - }), + window.focus(); + }, + catch: (cause) => + new ElectronWindowOperationError({ + operation: "reveal-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie), sendAll: (channel, ...args) => - Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) { + Effect.gen(function* () { + for (const window of yield* listWindows) { + if (yield* isWindowDestroyed(window)) { continue; } - window.webContents.send(channel, ...args); + yield* Effect.try({ + try: () => window.webContents.send(channel, ...args), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "send-window-message", + platform, + windowId: window.id, + channel, + cause, + }), + }).pipe(Effect.orDie); } }), - destroyAll: Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { - window.destroy(); + destroyAll: Effect.gen(function* () { + for (const window of yield* listWindows) { + yield* Effect.try({ + try: () => window.destroy(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "destroy-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie); } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( sync: (window: Electron.BrowserWindow) => Effect.Effect, ) { - const windows = Electron.BrowserWindow.getAllWindows(); + const windows = yield* listWindows; for (const window of windows) { - if (window.isDestroyed()) { + if (yield* isWindowDestroyed(window)) { continue; } yield* sync(window); From 4407a5a6b716b1d37bed917345d79f627a42f59e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:24:32 -0700 Subject: [PATCH 02/80] [codex] Structure Codex shadow home errors (#3262) Co-authored-by: codex --- .../provider/Drivers/CodexHomeLayout.test.ts | 60 +++- .../src/provider/Drivers/CodexHomeLayout.ts | 296 +++++++++++++----- 2 files changed, 273 insertions(+), 83 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 12e98293b12e..ec78b1665ef5 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -3,11 +3,13 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; import { - CodexShadowHomeError, + CodexShadowHomeEntryConflictError, + CodexShadowHomePathConflictError, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; @@ -184,7 +186,14 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - expect(error).toBeInstanceOf(CodexShadowHomeError); + expect(error).toBeInstanceOf(CodexShadowHomePathConflictError); + expect(error).toMatchObject({ + sharedHomePath: sharedHome, + effectiveHomePath: sharedHome, + }); + expect(error.message).toBe( + `Codex shadow home path '${sharedHome}' must be different from the shared home path '${sharedHome}'.`, + ); }), ); @@ -206,7 +215,52 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - expect(error.detail).toContain("already exists and is not a symlink"); + expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); + expect(error).toMatchObject({ + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + entryName: "config.toml", + linkPath: path.join(shadowHome, "config.toml"), + targetPath: path.join(sharedHome, "config.toml"), + }); + expect(error.message).toBe( + `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, + ); + }), + ); + + it.effect("preserves filesystem operation, paths, and cause", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedRoot = yield* makeTempDir("t3code-codex-shared-root-"); + const sharedHome = path.join(sharedRoot, "shared-home"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(sharedHome, "not a directory\n"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error._tag).toBe("CodexShadowHomeFileSystemError"); + if (error._tag !== "CodexShadowHomeFileSystemError") { + return expect.fail("Expected CodexShadowHomeFileSystemError"); + } + expect(error).toMatchObject({ + operation: "makeDirectory", + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + }); + expect(error.path.startsWith(sharedHome)).toBe(true); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect(error.message).toBe( + `Codex shadow home filesystem operation 'makeDirectory' failed for '${error.path}'.`, + ); }), ); }); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 5a7132224ef2..d2d09e9d8440 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -63,18 +63,71 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi }; }); -export class CodexShadowHomeError extends Schema.TaggedErrorClass()( - "CodexShadowHomeError", +const CodexShadowHomeContext = { + sharedHomePath: Schema.String, + effectiveHomePath: Schema.String, +}; + +export class CodexShadowHomeFileSystemError extends Schema.TaggedErrorClass()( + "CodexShadowHomeFileSystemError", + { + ...CodexShadowHomeContext, + operation: Schema.Literals(["readLink", "makeDirectory", "readDirectory", "remove", "symlink"]), + path: Schema.String, + targetPath: Schema.optional(Schema.String), + entryName: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const target = this.targetPath === undefined ? "" : ` to '${this.targetPath}'`; + return `Codex shadow home filesystem operation '${this.operation}' failed for '${this.path}'${target}.`; + } +} + +export class CodexShadowHomePathConflictError extends Schema.TaggedErrorClass()( + "CodexShadowHomePathConflictError", + CodexShadowHomeContext, +) { + override get message(): string { + return `Codex shadow home path '${this.effectiveHomePath}' must be different from the shared home path '${this.sharedHomePath}'.`; + } +} + +export class CodexShadowHomeEntryConflictError extends Schema.TaggedErrorClass()( + "CodexShadowHomeEntryConflictError", { - detail: Schema.String, - cause: Schema.optional(Schema.Unknown), + ...CodexShadowHomeContext, + entryName: Schema.String, + linkPath: Schema.String, + targetPath: Schema.String, }, ) { override get message(): string { - return this.detail; + return `Cannot create Codex shadow home entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; } } -const isCodexShadowHomeError = Schema.is(CodexShadowHomeError); + +export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedErrorClass()( + "CodexShadowHomePrivateEntrySymlinkError", + { + ...CodexShadowHomeContext, + entryName: Schema.String, + path: Schema.String, + }, +) { + override get message(): string { + return `Codex shadow home private entry '${this.entryName}' at '${this.path}' must be a real file, not a symlink.`; + } +} + +export const CodexShadowHomeError = Schema.Union([ + CodexShadowHomeFileSystemError, + CodexShadowHomePathConflictError, + CodexShadowHomeEntryConflictError, + CodexShadowHomePrivateEntrySymlinkError, +]); +export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; type LinkState = | { @@ -88,21 +141,6 @@ type LinkState = readonly target: string; }; -function toShadowHomeError(cause: unknown): CodexShadowHomeError { - return isCodexShadowHomeError(cause) - ? cause - : new CodexShadowHomeError({ - detail: "Failed to materialize Codex shadow home.", - cause, - }); -} - -function normalizeShadowHomeError( - effect: Effect.Effect, -): Effect.Effect { - return effect.pipe(Effect.mapError(toShadowHomeError)); -} - function isNotSymlinkError(error: PlatformError.PlatformError): boolean { const cause = error.reason.cause; return ( @@ -114,78 +152,151 @@ function isNotSymlinkError(error: PlatformError.PlatformError): boolean { ); } -const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( - fileSystem: FileSystem.FileSystem, - linkPath: string, -): Effect.fn.Return { - return yield* fileSystem.readLink(linkPath).pipe( +const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + readonly entryName: string; + readonly linkPath: string; +}): Effect.fn.Return { + return yield* input.fileSystem.readLink(input.linkPath).pipe( Effect.map((target): LinkState => ({ _tag: "Symlink", target })), - Effect.catch((error) => { - if (error.reason._tag === "NotFound") { - return Effect.succeed({ _tag: "Missing" }); - } - if (isNotSymlinkError(error)) { - return Effect.succeed({ _tag: "NotSymlink" }); - } - return Effect.fail(toShadowHomeError(error)); + Effect.catchTags({ + PlatformError: (cause) => { + if (cause.reason._tag === "NotFound") { + return Effect.succeed({ _tag: "Missing" }); + } + if (isNotSymlinkError(cause)) { + return Effect.succeed({ _tag: "NotSymlink" }); + } + return new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "readLink", + path: input.linkPath, + entryName: input.entryName, + cause, + }); + }, }), ); }); const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; - readonly shadowPath: string; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const privatePath = path.join(input.shadowPath, input.entryName); - const state = yield* readLinkState(input.fileSystem, privatePath); + const privatePath = path.join(input.effectiveHomePath, input.entryName); + const state = yield* readLinkState({ + ...input, + linkPath: privatePath, + }); if (state._tag === "Symlink") { - yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); + yield* input.fileSystem.remove(privatePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: privatePath, + entryName: input.entryName, + cause, + }), + }), + ); } }); const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; - readonly shadowPath: string; - readonly sharedPath: string; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const target = path.join(input.sharedPath, input.entryName); - const link = path.join(input.shadowPath, input.entryName); - const state = yield* readLinkState(input.fileSystem, link); + const target = path.join(input.sharedHomePath, input.entryName); + const link = path.join(input.effectiveHomePath, input.entryName); + const state = yield* readLinkState({ + ...input, + linkPath: link, + }); if (state._tag === "NotSymlink") { - return yield* new CodexShadowHomeError({ - detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, + return yield* new CodexShadowHomeEntryConflictError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName: input.entryName, + linkPath: link, + targetPath: target, }); } + const createLink = input.fileSystem.symlink(target, link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "symlink", + path: link, + targetPath: target, + entryName: input.entryName, + cause, + }), + }), + ); + if (state._tag === "Missing") { - return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + return yield* createLink; } const resolvedExisting = path.resolve(path.dirname(link), state.target); if (resolvedExisting !== target) { - yield* normalizeShadowHomeError(input.fileSystem.remove(link)); - yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + yield* input.fileSystem.remove(link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + yield* createLink; } }); -const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( - fileSystem: FileSystem.FileSystem, - shadowPath: string, -): Effect.fn.Return { - const path = yield* Path.Path; - const authPath = path.join(shadowPath, "auth.json"); - const state = yield* readLinkState(fileSystem, authPath); - if (state._tag === "Symlink") { - return yield* new CodexShadowHomeError({ - detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, +const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + }): Effect.fn.Return { + const path = yield* Path.Path; + const entryName = "auth.json"; + const authPath = path.join(input.effectiveHomePath, entryName); + const state = yield* readLinkState({ + ...input, + entryName, + linkPath: authPath, }); - } -}); + if (state._tag === "Symlink") { + return yield* new CodexShadowHomePrivateEntrySymlinkError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName, + path: authPath, + }); + } + }, +); export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( layout: CodexHomeLayout, @@ -194,31 +305,51 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" const effectiveHomePath = layout.effectiveHomePath; if (!effectiveHomePath) return; if (layout.sharedHomePath === effectiveHomePath) { - return yield* new CodexShadowHomeError({ - detail: "Codex shadow home path must be different from the shared home path.", + return yield* new CodexShadowHomePathConflictError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, }); } const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* normalizeShadowHomeError( - Effect.all( - [ - fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), - fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), - ...KNOWN_SHARED_DIRECTORIES.map((directory) => - fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { - recursive: true, + const makeDirectory = (directoryPath: string) => + fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "makeDirectory", + path: directoryPath, + cause, }), - ), - ], - { concurrency: "unbounded" }, - ), + }), + ); + + yield* Effect.all( + [ + makeDirectory(layout.sharedHomePath), + makeDirectory(effectiveHomePath), + ...KNOWN_SHARED_DIRECTORIES.map((directory) => + makeDirectory(path.join(layout.sharedHomePath, directory)), + ), + ], + { concurrency: "unbounded" }, ); - const sharedEntryNames = yield* normalizeShadowHomeError( - fileSystem.readDirectory(layout.sharedHomePath), + const sharedEntryNames = yield* fileSystem.readDirectory(layout.sharedHomePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "readDirectory", + path: layout.sharedHomePath, + cause, + }), + }), ); const entries = new Set(KNOWN_SHARED_DIRECTORIES); for (const entryName of sharedEntryNames) { @@ -234,7 +365,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" ? Effect.void : removePrivateSymlink({ fileSystem, - shadowPath: effectiveHomePath, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, entryName, }), { discard: true }, @@ -248,15 +380,19 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" } return ensureSymlink({ fileSystem, - shadowPath: effectiveHomePath, - sharedPath: layout.sharedHomePath, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, entryName, }); }, { discard: true }, ); - yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); + yield* ensureShadowAuthIsPrivate({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + }); }); export function codexContinuationIdentity(layout: CodexHomeLayout) { From 4c16c66368c934a3f23794fc4826a2c77fe9f323 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:25:16 -0700 Subject: [PATCH 03/80] [codex] Structure ACP transport errors (#3251) Co-authored-by: codex --- packages/effect-acp/src/_internal/shared.ts | 46 +++-- packages/effect-acp/src/_internal/stdio.ts | 2 +- packages/effect-acp/src/agent.ts | 94 ++++++----- packages/effect-acp/src/client.test.ts | 6 +- packages/effect-acp/src/client.ts | 37 ++-- packages/effect-acp/src/errors.test.ts | 144 ++++++++++++++++ packages/effect-acp/src/errors.ts | 178 +++++++++++++++++++- packages/effect-acp/src/protocol.test.ts | 136 ++++++++++++++- packages/effect-acp/src/protocol.ts | 82 ++++----- packages/effect-acp/src/terminal.ts | 20 --- 10 files changed, 593 insertions(+), 152 deletions(-) create mode 100644 packages/effect-acp/src/errors.test.ts diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index 937d931c4041..7e43bbf8831e 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -1,30 +1,29 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { RpcClientError } from "effect/unstable/rpc"; import * as AcpSchema from "../_generated/schema.gen.ts"; import * as AcpError from "../errors.ts"; const isError = Schema.is(AcpSchema.Error); -const isAcpRequestError = Schema.is(AcpError.AcpRequestError); - -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); export const callRpc = ( + method: string, effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTag("RpcClientError", (error) => - Effect.fail( - new AcpError.AcpTransportError({ - detail: error.message, - cause: error, - }), - ), - ), Effect.catchIf(isError, (error) => Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), ), + Effect.catchTags({ + RpcClientError: (cause) => + Effect.fail( + new AcpError.AcpTransportError({ + operation: "call-rpc", + method, + cause, + }), + ), + }), ); export const runHandler = Effect.fnUntraced(function* ( @@ -37,9 +36,7 @@ export const runHandler = Effect.fnUntraced(function* ( } return yield* handler(payload).pipe( Effect.mapError((error) => - isAcpRequestError(error) - ? error.toProtocolError() - : AcpError.AcpRequestError.internalError(error.message).toProtocolError(), + AcpError.AcpRequestError.fromCoreHandlerError(error, method).toProtocolError(), ), ); }); @@ -51,12 +48,7 @@ export function decodeExtRequestRegistration( ) { return (params: unknown): Effect.Effect => Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError((error) => - AcpError.AcpRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), - ), + Effect.mapError((error) => AcpError.AcpRequestError.invalidExtensionPayload(method, error)), Effect.flatMap((decoded) => handler(decoded)), ); } @@ -68,12 +60,12 @@ export function decodeExtNotificationRegistration( ) { return (params: unknown): Effect.Effect => Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError( - (error) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`, - cause: error, - }), + Effect.mapError((error) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + method, + error, + ), ), Effect.flatMap((decoded) => handler(decoded)), ); diff --git a/packages/effect-acp/src/_internal/stdio.ts b/packages/effect-acp/src/_internal/stdio.ts index 8ddb4d37d0f7..393a1c591cbf 100644 --- a/packages/effect-acp/src/_internal/stdio.ts +++ b/packages/effect-acp/src/_internal/stdio.ts @@ -50,7 +50,7 @@ export const makeTerminationError = ( Effect.match(handle.exitCode, { onFailure: (cause) => new AcpError.AcpTransportError({ - detail: "Failed to determine ACP process exit status", + operation: "read-process-exit-status", cause, }), onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }), diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 5cad53c3d12d..307028b0a80e 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -288,12 +288,12 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( notification.method === AGENT_METHODS.session_cancel ) { return decodeCancelNotification(notification.params).pipe( - Effect.mapError( - (error) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${AGENT_METHODS.session_cancel} notification payload`, - cause: error, - }), + Effect.mapError((error) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + AGENT_METHODS.session_cancel, + error, + ), ), Effect.flatMap((decoded) => Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }), @@ -376,41 +376,55 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( }, client: { requestPermission: (payload) => - callRpc(rpc[CLIENT_METHODS.session_request_permission](payload)), - elicit: (payload) => callRpc(rpc[CLIENT_METHODS.session_elicitation](payload)), - readTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_read_text_file](payload)), - writeTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_write_text_file](payload)), + callRpc( + CLIENT_METHODS.session_request_permission, + rpc[CLIENT_METHODS.session_request_permission](payload), + ), + elicit: (payload) => + callRpc( + CLIENT_METHODS.session_elicitation, + rpc[CLIENT_METHODS.session_elicitation](payload), + ), + readTextFile: (payload) => + callRpc(CLIENT_METHODS.fs_read_text_file, rpc[CLIENT_METHODS.fs_read_text_file](payload)), + writeTextFile: (payload) => + callRpc(CLIENT_METHODS.fs_write_text_file, rpc[CLIENT_METHODS.fs_write_text_file](payload)), createTerminal: (payload) => - callRpc(rpc[CLIENT_METHODS.terminal_create](payload)).pipe( - Effect.map((response) => - AcpTerminal.makeTerminal({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - output: callRpc( - rpc[CLIENT_METHODS.terminal_output]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - waitForExit: callRpc( - rpc[CLIENT_METHODS.terminal_wait_for_exit]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - kill: callRpc( - rpc[CLIENT_METHODS.terminal_kill]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - release: callRpc( - rpc[CLIENT_METHODS.terminal_release]({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - ), - }), + callRpc(CLIENT_METHODS.terminal_create, rpc[CLIENT_METHODS.terminal_create](payload)).pipe( + Effect.map( + (response) => + ({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + output: callRpc( + CLIENT_METHODS.terminal_output, + rpc[CLIENT_METHODS.terminal_output]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + waitForExit: callRpc( + CLIENT_METHODS.terminal_wait_for_exit, + rpc[CLIENT_METHODS.terminal_wait_for_exit]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + kill: callRpc( + CLIENT_METHODS.terminal_kill, + rpc[CLIENT_METHODS.terminal_kill]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + release: callRpc( + CLIENT_METHODS.terminal_release, + rpc[CLIENT_METHODS.terminal_release]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + }) satisfies AcpTerminal.AcpTerminal, ), ), sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload), diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index aca87d45c627..c732f80ef353 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -147,7 +147,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { ); it.effect( - "returns formatted invalid params when a typed extension request payload is wrong", + "returns structured invalid params without exposing values from typed extension request payloads", () => Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" }); @@ -213,8 +213,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { assert.fail("Expected prompt to fail for invalid typed extension payload"); } const rendered = Cause.pretty(result.cause); - assert.include(rendered, "Invalid x/typed_request payload:"); - assert.include(rendered, "Expected string, got 123"); + assert.include(rendered, "Invalid payload for ACP extension method 'x/typed_request'."); + assert.notInclude(rendered, "Expected string, got 123"); }), ); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 6f3d6a0c9f85..61b3d71b49de 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -462,19 +462,32 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( notify: transport.notify, }, agent: { - initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)), - authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)), - logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)), - createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)), - loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)), - listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)), - forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)), - resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)), - closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)), - setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)), + initialize: (payload) => + callRpc(AGENT_METHODS.initialize, rpc[AGENT_METHODS.initialize](payload)), + authenticate: (payload) => + callRpc(AGENT_METHODS.authenticate, rpc[AGENT_METHODS.authenticate](payload)), + logout: (payload) => callRpc(AGENT_METHODS.logout, rpc[AGENT_METHODS.logout](payload)), + createSession: (payload) => + callRpc(AGENT_METHODS.session_new, rpc[AGENT_METHODS.session_new](payload)), + loadSession: (payload) => + callRpc(AGENT_METHODS.session_load, rpc[AGENT_METHODS.session_load](payload)), + listSessions: (payload) => + callRpc(AGENT_METHODS.session_list, rpc[AGENT_METHODS.session_list](payload)), + forkSession: (payload) => + callRpc(AGENT_METHODS.session_fork, rpc[AGENT_METHODS.session_fork](payload)), + resumeSession: (payload) => + callRpc(AGENT_METHODS.session_resume, rpc[AGENT_METHODS.session_resume](payload)), + closeSession: (payload) => + callRpc(AGENT_METHODS.session_close, rpc[AGENT_METHODS.session_close](payload)), + setSessionModel: (payload) => + callRpc(AGENT_METHODS.session_set_model, rpc[AGENT_METHODS.session_set_model](payload)), setSessionConfigOption: (payload) => - callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)), - prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)), + callRpc( + AGENT_METHODS.session_set_config_option, + rpc[AGENT_METHODS.session_set_config_option](payload), + ), + prompt: (payload) => + callRpc(AGENT_METHODS.session_prompt, rpc[AGENT_METHODS.session_prompt](payload)), cancel: (payload) => transport.notify(AGENT_METHODS.session_cancel, payload), }, handleRequestPermission: (handler) => diff --git a/packages/effect-acp/src/errors.test.ts b/packages/effect-acp/src/errors.test.ts new file mode 100644 index 000000000000..5187fabf5d20 --- /dev/null +++ b/packages/effect-acp/src/errors.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; + +import * as AcpSchema from "./_generated/schema.gen.ts"; +import { callRpc, runHandler } from "./_internal/shared.ts"; +import * as AcpError from "./errors.ts"; + +const decodeNestedNumberPayload = Schema.decodeUnknownEffect( + Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }), +); +const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +describe("effect-acp errors", () => { + it.effect("retains RPC method and cause without deriving the message from the cause", () => { + const rootCause = new Error("connection details that must not become the public message"); + const failure = new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: rootCause.message, + cause: rootCause, + }), + }); + + return Effect.gen(function* () { + const error = yield* callRpc("session/new", Effect.fail(failure)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpTransportError", + operation: "call-rpc", + method: "session/new", + cause: failure, + }); + expect(error.message).toBe("ACP transport operation call-rpc failed for method session/new."); + expect(error.message).not.toContain(rootCause.message); + }); + }); + + it.effect("preserves protocol request errors as request errors", () => { + const failure = AcpSchema.Error.make({ + code: -32602, + message: "Invalid params", + data: { field: "sessionId" }, + }); + + return Effect.gen(function* () { + const error = yield* callRpc("session/load", Effect.fail(failure)).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Invalid params", + data: { field: "sessionId" }, + }); + }); + }); + + it("does not expose legacy diagnostic detail as the transport message", () => { + const cause = new Error("connection refused at a private endpoint"); + const error = new AcpError.AcpTransportError({ + detail: cause.message, + cause, + }); + + expect(error.message).toBe("ACP transport operation failed."); + expect(error.cause).toBe(cause); + }); + + it("preserves structured extension handler failures behind stable request errors", () => { + const cause = new AcpError.AcpTransportError({ + operation: "read-input-stream", + cause: new Error("private transport diagnostics"), + }); + const error = AcpError.AcpRequestError.fromExtensionHandlerError(cause, "x/test"); + + expect(error).toMatchObject({ + code: -32603, + method: "x/test", + operation: "handle-extension-request", + cause, + }); + expect(error.message).toBe("ACP extension request handler failed for method 'x/test'"); + expect(error.message).not.toContain(cause.message); + }); + + it.effect("uses the structured mapper for core handler failures", () => { + const cause = new AcpError.AcpTransportError({ + operation: "read-input-stream", + cause: new Error("private transport diagnostics"), + }); + + return Effect.gen(function* () { + const error = yield* runHandler(() => Effect.fail(cause), {}, "fs/read_text_file").pipe( + Effect.flip, + ); + + expect(error).toMatchObject({ + code: -32603, + message: "ACP request handler failed for method 'fs/read_text_file'", + }); + expect(error.message).not.toContain(cause.message); + }); + }); + + it.effect("keeps invalid extension payload values only in the exact schema cause", () => + Effect.gen(function* () { + const secret = "acp-schema-payload-secret"; + const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe( + Effect.flip, + ); + const error = AcpError.AcpRequestError.invalidExtensionPayload("x/private", cause); + const { cause: directCause, ...directDiagnostics } = error; + + expect(directCause).toBe(cause); + expect(error).toMatchObject({ + method: "x/private", + operation: "decode-extension-request-payload", + maximumPathDepth: 2, + }); + expect(error.issueCount).toBeGreaterThan(0); + expect(error.issueKinds).toContain("Pointer"); + expect(error.message).toBe("Invalid payload for ACP extension method 'x/private'."); + expect(error.message).not.toContain(secret); + expect(encodeUnknownJson(directDiagnostics)).not.toContain(secret); + expect(encodeUnknownJson(error.toProtocolError())).not.toContain(secret); + + const protocolError = AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + "x/private", + cause, + ); + const { cause: protocolCause, ...protocolDiagnostics } = protocolError; + expect(protocolCause).toBe(cause); + expect(protocolError).toMatchObject({ + method: "x/private", + operation: "decode-notification-payload", + maximumPathDepth: 2, + }); + expect(protocolError.message).not.toContain(secret); + expect(encodeUnknownJson(protocolDiagnostics)).not.toContain(secret); + expect("detail" in protocolError).toBe(false); + }), + ); +}); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 91668f841f9d..b3c0dee62942 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -1,7 +1,77 @@ import * as Schema from "effect/Schema"; +import type * as SchemaIssue from "effect/SchemaIssue"; import * as AcpSchema from "./_generated/schema.gen.ts"; +export const AcpRequestOperation = Schema.Literals([ + "decode-extension-request-payload", + "handle-request", + "handle-extension-request", +]); +export type AcpRequestOperation = typeof AcpRequestOperation.Type; + +export const AcpSchemaIssueKind = Schema.Literals([ + "Filter", + "Encoding", + "Pointer", + "Composite", + "AnyOf", + "InvalidType", + "InvalidValue", + "MissingKey", + "UnexpectedKey", + "Forbidden", + "OneOf", +]); +export type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; + +export interface AcpSchemaIssueDiagnostics { + readonly issueCount: number; + readonly issueKinds: ReadonlyArray; + readonly maximumPathDepth: number; +} + +const schemaIssueDiagnostics = (root: SchemaIssue.Issue): AcpSchemaIssueDiagnostics => { + let issueCount = 0; + let maximumPathDepth = 0; + const issueKinds = new Set(); + + const visit = (issue: SchemaIssue.Issue, pathDepth: number): void => { + issueCount += 1; + issueKinds.add(issue._tag); + maximumPathDepth = Math.max(maximumPathDepth, pathDepth); + switch (issue._tag) { + case "Filter": + case "Encoding": + visit(issue.issue, pathDepth); + break; + case "Pointer": + visit(issue.issue, pathDepth + issue.path.length); + break; + case "Composite": + case "AnyOf": + for (const child of issue.issues) visit(child, pathDepth); + break; + } + }; + + visit(root, 0); + return { + issueCount, + issueKinds: [...issueKinds], + maximumPathDepth, + }; +}; + +export interface AcpRequestDiagnostics { + readonly method?: string; + readonly operation?: AcpRequestOperation; + readonly cause?: unknown; + readonly issueCount?: number; + readonly issueKinds?: ReadonlyArray; + readonly maximumPathDepth?: number; +} + export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { command: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -27,27 +97,68 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass()( "AcpProtocolParseError", { - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: AcpProtocolParseOperation, + method: Schema.optionalKey(Schema.String), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), + cause: Schema.Defect(), }, ) { override get message() { - return `Failed to parse ACP protocol message: ${this.detail}`; + const method = this.method === undefined ? "" : ` for method '${this.method}'`; + return `ACP protocol operation '${this.operation}' failed${method}.`; + } + + static fromSchemaError( + operation: AcpProtocolParseOperation, + method: string, + cause: Schema.SchemaError, + ) { + return new AcpProtocolParseError({ + operation, + method, + ...schemaIssueDiagnostics(cause.issue), + cause, + }); } } export class AcpTransportError extends Schema.TaggedErrorClass()( "AcpTransportError", { - detail: Schema.String, + operation: Schema.optional( + Schema.Literals(["call-rpc", "read-input-stream", "read-process-exit-status"]), + ), + method: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { override get message() { - return this.detail; + const method = this.method ? ` for method ${this.method}` : ""; + return this.operation + ? `ACP transport operation ${this.operation} failed${method}.` + : "ACP transport operation failed."; + } +} + +export class AcpInputStreamEndedError extends Schema.TaggedErrorClass()( + "AcpInputStreamEndedError", + {}, +) { + override get message() { + return "ACP input stream ended."; } } @@ -55,6 +166,12 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( code: AcpSchema.ErrorCode, errorMessage: Schema.String, data: Schema.optional(Schema.Unknown), + method: Schema.optionalKey(Schema.String), + operation: Schema.optionalKey(AcpRequestOperation), + issueCount: Schema.optionalKey(Schema.Number), + issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), + maximumPathDepth: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), }) { override get message() { return this.errorMessage; @@ -68,6 +185,36 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( }); } + static fromCoreHandlerError(error: AcpError, method: string) { + if (error._tag === "AcpRequestError") { + return error; + } + return AcpRequestError.internalError( + `ACP request handler failed for method '${method}'`, + undefined, + { + method, + operation: "handle-request", + cause: error, + }, + ); + } + + static fromExtensionHandlerError(error: AcpError, method: string) { + if (error._tag === "AcpRequestError") { + return error; + } + return AcpRequestError.internalError( + `ACP extension request handler failed for method '${method}'`, + undefined, + { + method, + operation: "handle-extension-request", + cause: error, + }, + ); + } + static parseError(message = "Parse error", data?: unknown) { return new AcpRequestError({ code: -32700, @@ -99,11 +246,29 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( }); } - static internalError(message = "Internal error", data?: unknown) { + static invalidExtensionPayload(method: string, cause: Schema.SchemaError) { + const diagnostics = schemaIssueDiagnostics(cause.issue); + return new AcpRequestError({ + code: -32602, + errorMessage: `Invalid payload for ACP extension method '${method}'.`, + data: diagnostics, + method, + operation: "decode-extension-request-payload", + ...diagnostics, + cause, + }); + } + + static internalError( + message = "Internal error", + data?: unknown, + diagnostics: AcpRequestDiagnostics = {}, + ) { return new AcpRequestError({ code: -32603, errorMessage: message, ...(data !== undefined ? { data } : {}), + ...diagnostics, }); } @@ -138,6 +303,7 @@ export const AcpError = Schema.Union([ AcpProcessExitedError, AcpProtocolParseError, AcpTransportError, + AcpInputStreamEndedError, ]); export type AcpError = typeof AcpError.Type; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 093d4acfcfa2..c8e03dd72351 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -48,6 +48,8 @@ const decodeExtRequest = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); const decodeRequestPermissionResponse = Schema.decodeEffect( Schema.fromJsonString(RequestPermissionResponse), ); +const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encoder = new TextEncoder(); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), ); @@ -132,6 +134,49 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps invalid core notification values only in the schema cause", () => + Effect.gen(function* () { + const secret = "acp-core-notification-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: { secret }, + update: { + sessionUpdate: "plan", + entries: [], + }, + }, + })}\n`, + ), + ); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, AcpError.AcpProtocolParseError); + const parseError = error as AcpError.AcpProtocolParseError; + const { cause, ...directDiagnostics } = parseError; + assert.equal(parseError.operation, "decode-notification-payload"); + assert.equal(parseError.method, "session/update"); + assert.isAbove(parseError.issueCount ?? 0, 0); + assert.include(parseError.issueKinds ?? [], "Pointer"); + assert.isAbove(parseError.maximumPathDepth ?? 0, 0); + assert.isTrue(Schema.isSchemaError(cause)); + assert.notInclude(parseError.message, secret); + assert.notInclude(encodeUnknownJsonString(directDiagnostics), secret); + }), + ); + it.effect("logs outgoing notifications when logOutgoing is enabled", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -172,6 +217,38 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("logs decode failures without copying the cause or wire payload", () => + Effect.gen(function* () { + const secret = "acp-wire-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const events: Array = []; + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + logIncoming: true, + logger: (event) => + Effect.sync(() => { + events.push(event); + }), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`)); + yield* Deferred.await(termination); + + const event = events.find(({ stage }) => stage === "decode_failed"); + assert.deepEqual(event, { + direction: "incoming", + stage: "decode_failed", + payload: { + operation: "decode-wire-message", + }, + }); + assert.notInclude(encodeUnknownJsonString(event), secret); + }), + ); + it.effect("fails notification encoding through the declared ACP error channel", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -182,13 +259,16 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); - assert.equal(bigintError.detail, "Failed to encode ACP message"); + assert.equal(bigintError.operation, "encode-message"); + assert.instanceOf(bigintError.cause, TypeError); + assert.equal(bigintError.message, "ACP protocol operation 'encode-message' failed."); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, AcpError.AcpProtocolParseError); - assert.equal(circularError.detail, "Failed to encode ACP message"); + assert.equal(circularError.operation, "encode-message"); + assert.instanceOf(circularError.cause, TypeError); }), ); @@ -381,14 +461,35 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; + readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "ACP protocol terminated."); assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError); assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7); }), ); + it.effect("classifies an input stream ending without inventing a cause", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.end(input); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, AcpError.AcpInputStreamEndedError); + assert.equal(error.message, "ACP input stream ended."); + assert.equal("cause" in error, false); + }), + ); + it.effect("does not emit a second process-exit error after a decode failure", () => Effect.gen(function* () { const handle = yield* makeHandle({ @@ -413,9 +514,40 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; + readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "ACP protocol terminated."); + assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); + }), + ); + + it.effect("keeps client send failure messages independent from the cause", () => + Effect.gen(function* () { + const { stdio } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const failure = yield* transport.clientProtocol + .send(0, { + _tag: "Request", + id: "request-1", + tag: "x/test", + payload: 1n, + headers: [], + }) + .pipe(Effect.flip); + const defect = failure.reason as { + readonly _tag: string; + readonly message: string; + readonly cause: unknown; + }; + + assert.equal(defect._tag, "RpcClientDefect"); + assert.equal(defect.message, "Failed to send ACP protocol message."); assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); }), ); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 56a7ce81ab82..6c3bd399028f 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -17,7 +17,6 @@ import * as AcpSchema from "./_generated/schema.gen.ts"; import { CLIENT_METHODS } from "./_generated/meta.gen.ts"; import * as AcpError from "./errors.ts"; const isAcpError = Schema.is(AcpError.AcpError); -const isAcpRequestError = Schema.is(AcpError.AcpRequestError); export interface AcpProtocolLogEvent { readonly direction: "incoming" | "outgoing"; @@ -114,7 +113,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi try: () => parser.encode(message), catch: (cause) => new AcpError.AcpProtocolParseError({ - detail: "Failed to encode ACP message", + operation: "encode-message", cause, }), }); @@ -184,7 +183,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi _tag: "ClientProtocolError", error: new RpcClientError.RpcClientError({ reason: new RpcClientError.RpcClientDefect({ - message: error.message, + message: "ACP protocol terminated.", cause: error, }), }), @@ -243,7 +242,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi } return options.onExtRequest(message.tag, message.payload).pipe( Effect.matchEffect({ - onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)), + onFailure: (error) => + respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionHandlerError(error, message.tag), + ), onSuccess: (value) => respondWithSuccess(message.id, value), }), ); @@ -261,12 +264,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi params, }) satisfies AcpIncomingNotification, ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`, - cause, - }), + Effect.mapError((cause) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + CLIENT_METHODS.session_update, + cause, + ), ), Effect.flatMap(dispatchNotification), ); @@ -281,12 +284,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi params, }) satisfies AcpIncomingNotification, ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`, - cause, - }), + Effect.mapError((cause) => + AcpError.AcpProtocolParseError.fromSchemaError( + "decode-notification-payload", + CLIENT_METHODS.session_elicitation_complete, + cause, + ), ), Effect.flatMap(dispatchNotification), ); @@ -379,7 +382,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi >, catch: (cause) => new AcpError.AcpProtocolParseError({ - detail: "Failed to decode ACP wire message", + operation: "decode-wire-message", cause, }), }), @@ -396,8 +399,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi direction: "incoming", stage: "decode_failed", payload: { - detail: error.detail, - cause: error.cause, + operation: error.operation, + ...(error.method === undefined ? {} : { method: error.method }), + ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), + ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), + ...(error.maximumPathDepth === undefined + ? {} + : { maximumPathDepth: error.maximumPathDepth }), }, }), ), @@ -413,7 +421,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const normalized: AcpError.AcpError = isAcpError(error) ? error : new AcpError.AcpTransportError({ - detail: error instanceof Error ? error.message : String(error), + operation: "read-input-stream", cause: error, }); return handleTermination(() => Effect.succeed(normalized)); @@ -421,13 +429,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi onSuccess: () => handleTermination( () => - options.terminationError ?? - Effect.succeed( - new AcpError.AcpTransportError({ - detail: "ACP input stream ended", - cause: new Error("ACP input stream ended"), - }), - ), + options.terminationError ?? Effect.succeed(new AcpError.AcpInputStreamEndedError({})), ), }), Effect.forkScoped, @@ -441,7 +443,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Stream.runForEach((message) => f(message)), Effect.forever, ), - send: (_clientId, request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)), + send: (_clientId, request) => + offerOutgoing(request).pipe( + Effect.mapError( + (error) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Failed to send ACP protocol message.", + cause: error, + }), + }), + ), + ), supportsAck: true, supportsTransferables: false, }); @@ -521,16 +534,3 @@ function isProtocolError( typeof value.message === "string" ); } - -function normalizeToRequestError(error: AcpError.AcpError): AcpError.AcpRequestError { - return isAcpRequestError(error) ? error : AcpError.AcpRequestError.internalError(error.message); -} - -function toRpcClientError(error: AcpError.AcpError): RpcClientError.RpcClientError { - return new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: error.message, - cause: error, - }), - }); -} diff --git a/packages/effect-acp/src/terminal.ts b/packages/effect-acp/src/terminal.ts index 088ff8637384..b892f040436e 100644 --- a/packages/effect-acp/src/terminal.ts +++ b/packages/effect-acp/src/terminal.ts @@ -23,23 +23,3 @@ export interface AcpTerminal { */ readonly release: Effect.Effect; } - -export interface MakeTerminalOptions { - readonly sessionId: string; - readonly terminalId: string; - readonly output: Effect.Effect; - readonly waitForExit: Effect.Effect; - readonly kill: Effect.Effect; - readonly release: Effect.Effect; -} - -export function makeTerminal(options: MakeTerminalOptions): AcpTerminal { - return { - sessionId: options.sessionId, - terminalId: options.terminalId, - output: options.output, - waitForExit: options.waitForExit, - kill: options.kill, - release: options.release, - }; -} From 7b791895ad043e180c1d56595a08866e16d0766e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:27:28 -0700 Subject: [PATCH 04/80] [codex] Structure relay auth persistence errors (#3250) Co-authored-by: codex --- infra/relay/src/auth/DpopProofs.test.ts | 26 ++ infra/relay/src/auth/DpopProofs.ts | 41 ++- .../auth/DpopProofs.verifyAndConsume.test.ts | 14 +- .../EnvironmentCredentials.test.ts | 82 ++++++ .../environments/EnvironmentCredentials.ts | 268 +++++++++++------- .../src/environments/EnvironmentLinks.test.ts | 70 +++++ .../src/environments/EnvironmentLinks.ts | 225 ++++++++++----- infra/relay/src/http/Api.test.ts | 1 + infra/relay/src/http/Api.ts | 96 +++++-- 9 files changed, 614 insertions(+), 209 deletions(-) diff --git a/infra/relay/src/auth/DpopProofs.test.ts b/infra/relay/src/auth/DpopProofs.test.ts index b294ba396b67..fba64586e285 100644 --- a/infra/relay/src/auth/DpopProofs.test.ts +++ b/infra/relay/src/auth/DpopProofs.test.ts @@ -96,4 +96,30 @@ describe("DpopProofReplay", () => { Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), ); }); + + it.effect("retains the prune cutoff and database failure", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + delete: (table: unknown) => { + expect(table).toBe(relayDpopProofs); + return { + where: () => Effect.fail(cause), + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const error = yield* Effect.flip(replay.pruneExpired); + + expect(error).toMatchObject({ + _tag: "DpopProofReplayPersistenceError", + operation: "prune-expired", + }); + expect(Date.parse(error.expiresBefore ?? "")).not.toBeNaN(); + expect(error.cause).toBe(cause); + }).pipe( + Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); + }); }); diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts index cf3f7a4cf5a9..fa784eb639b6 100644 --- a/infra/relay/src/auth/DpopProofs.ts +++ b/infra/relay/src/auth/DpopProofs.ts @@ -13,11 +13,16 @@ import { relayDpopProofs } from "../persistence/schema.ts"; export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass()( "DpopProofReplayPersistenceError", { + operation: Schema.Literals(["consume", "prune-expired"]), + thumbprint: Schema.optionalKey(Schema.String), + jti: Schema.optionalKey(Schema.String), + iat: Schema.optionalKey(Schema.Number), + expiresBefore: Schema.optionalKey(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to persist DPoP proof replay state"; + return `Failed to persist DPoP proof replay state during '${this.operation}'`; } } @@ -58,10 +63,21 @@ const make = Effect.gen(function* () { createdAt, }) .onConflictDoNothing() - .returning({ jti: relayDpopProofs.jti }); + .returning({ jti: relayDpopProofs.jti }) + .pipe( + Effect.mapError( + (cause) => + new DpopProofReplayPersistenceError({ + operation: "consume", + thumbprint: input.thumbprint, + jti: input.jti, + iat: input.iat, + cause, + }), + ), + ); return inserted.length > 0; }, - Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), ); const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn( @@ -114,11 +130,20 @@ const make = Effect.gen(function* () { const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now }); - yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now)); - }).pipe( - Effect.withSpan("relay.dpop_proofs.prune_expired"), - Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), - ); + yield* db + .delete(relayDpopProofs) + .where(lt(relayDpopProofs.expiresAt, now)) + .pipe( + Effect.mapError( + (cause) => + new DpopProofReplayPersistenceError({ + operation: "prune-expired", + expiresBefore: now, + cause, + }), + ), + ); + }).pipe(Effect.withSpan("relay.dpop_proofs.prune_expired")); return DpopProofReplay.of({ verifyAndConsume, diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts index ecb33f1fc067..7663e874879b 100644 --- a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -163,7 +163,7 @@ describe("DpopProofReplay.verifyAndConsume", () => { iat: Math.floor(now.epochMilliseconds / 1_000), jti: "proof-persistence-failure", }); - const cause = "database unavailable"; + const cause = { _tag: "DatabaseUnavailable" } as const; return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; @@ -177,8 +177,16 @@ describe("DpopProofReplay.verifyAndConsume", () => { }), ); - expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause })); - }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause })))); + expect(error).toMatchObject({ + _tag: "DpopProofReplayPersistenceError", + operation: "consume", + thumbprint: proof.thumbprint, + jti: "proof-persistence-failure", + iat: Math.floor(now.epochMilliseconds / 1_000), + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("proof"); + }).pipe(Effect.provide(layer(() => Effect.fail(cause)))); }); it.effect("accepts proofs bound to the access token hash", () => { diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts index 733658cbb5e9..4e12dabe831f 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -9,6 +9,88 @@ import { relayEnvironmentCredentials } from "../persistence/schema.ts"; import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; describe("EnvironmentCredentials", () => { + it.effect("reports the credential creation persistence stage and preserves its cause", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + values: () => Effect.void, + }; + }, + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + set: () => ({ + where: () => Effect.fail(cause), + }), + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const error = yield* Effect.flip( + credentials.create({ + environmentId: "env_test", + environmentPublicKey: "sensitive-public-key-material", + }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentCredentialCreatePersistenceError", + stage: "revoke-previous-credentials", + environmentId: "env_test", + }); + expect(error.credentialId).toMatch(/^[0-9a-f]{64}$/); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("environmentPublicKey"); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("does not retain credential tokens when lookup persistence fails", () => { + const cause = new Error("database unavailable"); + const token = "t3env_sensitive-credential-token"; + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const error = yield* Effect.flip(credentials.authenticate(token)); + + expect(error).toMatchObject({ + _tag: "EnvironmentCredentialAuthenticatePersistenceError", + stage: "lookup-credential", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("token"); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), + ), + ), + ); + }); + it.effect( "creates opaque credentials and revokes only older credentials for the same key", () => { diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index e318ce1e0983..39f40d941b8d 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -13,28 +13,44 @@ import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persisten export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialCreatePersistenceError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals([ + "generate-credential", + "hash-token", + "insert-credential", + "revoke-previous-credentials", + ]), + environmentId: Schema.String, + credentialId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist environment credential"; + return `Environment credential creation failed during '${this.stage}' for environment '${this.environmentId}'${this.credentialId === undefined ? "" : `, credential '${this.credentialId}'`}`; } } export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialAuthenticatePersistenceError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals(["hash-token", "lookup-credential"]), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to authenticate environment credential"; + return `Environment credential authentication failed during '${this.stage}'`; } } export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentCredentialRevokePersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to revoke environment credential"; + return `Failed to revoke credentials for environment '${this.environmentId}'`; } } @@ -85,13 +101,33 @@ const make = Effect.gen(function* () { }); return EnvironmentCredentials.of({ - create: Effect.fn("relay.environment_credentials.create")( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); - const credential = yield* makeCredential(); - const credentialHash = yield* hashToken(credential.token); - const now = DateTime.formatIso(yield* DateTime.now); - yield* db.insert(relayEnvironmentCredentials).values({ + create: Effect.fn("relay.environment_credentials.create")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const credential = yield* makeCredential().pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "generate-credential", + environmentId: input.environmentId, + cause, + }), + ), + ); + const credentialHash = yield* hashToken(credential.token).pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "hash-token", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + const now = DateTime.formatIso(yield* DateTime.now); + yield* db + .insert(relayEnvironmentCredentials) + .values({ credentialId: credential.credentialId, environmentId: input.environmentId, environmentPublicKey: input.environmentPublicKey, @@ -99,96 +135,136 @@ const make = Effect.gen(function* () { revokedAt: null, createdAt: now, updatedAt: now, - }); - yield* db - .update(relayEnvironmentCredentials) - .set({ - revokedAt: now, - updatedAt: now, - }) - .where( - and( - eq(relayEnvironmentCredentials.environmentId, input.environmentId), - eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), - ne(relayEnvironmentCredentials.credentialId, credential.credentialId), - isNull(relayEnvironmentCredentials.revokedAt), - ), - ); - return credential.token; - }, - Effect.mapError((cause) => new EnvironmentCredentialCreatePersistenceError({ cause })), - ), + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "insert-credential", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt: now, + updatedAt: now, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + ne(relayEnvironmentCredentials.credentialId, credential.credentialId), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialCreatePersistenceError({ + stage: "revoke-previous-credentials", + environmentId: input.environmentId, + credentialId: credential.credentialId, + cause, + }), + ), + ); + return credential.token; + }), - authenticate: Effect.fn("relay.environment_credentials.authenticate")( - function* (token) { - const credentialHash = yield* hashToken(token); - const rows = yield* db - .select({ - credentialId: relayEnvironmentCredentials.credentialId, - environmentId: relayEnvironmentCredentials.environmentId, - environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, + authenticate: Effect.fn("relay.environment_credentials.authenticate")(function* (token) { + const credentialHash = yield* hashToken(token).pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialAuthenticatePersistenceError({ + stage: "hash-token", + cause, + }), + ), + ); + const rows = yield* db + .select({ + credentialId: relayEnvironmentCredentials.credentialId, + environmentId: relayEnvironmentCredentials.environmentId, + environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, + }) + .from(relayEnvironmentCredentials) + .where( + and( + eq(relayEnvironmentCredentials.credentialHash, credentialHash), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ) + .limit(1) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialAuthenticatePersistenceError({ + stage: "lookup-credential", + cause, + }), + ), + ); + const row = rows[0]; + if (row) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); + } + return row + ? Option.some({ + credentialId: row.credentialId, + environmentId: row.environmentId, + environmentPublicKey: row.environmentPublicKey, }) - .from(relayEnvironmentCredentials) - .where( - and( - eq(relayEnvironmentCredentials.credentialHash, credentialHash), - isNull(relayEnvironmentCredentials.revokedAt), - ), - ) - .limit(1); - const row = rows[0]; - if (row) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); - } - return row - ? Option.some({ - credentialId: row.credentialId, - environmentId: row.environmentId, - environmentPublicKey: row.environmentPublicKey, - }) - : Option.none(); - }, - Effect.mapError((cause) => new EnvironmentCredentialAuthenticatePersistenceError({ cause })), - ), + : Option.none(); + }), revokeForEnvironmentPublicKey: Effect.fn( "relay.environment_credentials.revoke_for_environment_public_key", - )( - function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); - const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db - .update(relayEnvironmentCredentials) - .set({ - revokedAt, - updatedAt: revokedAt, - }) - .where( - and( - eq(relayEnvironmentCredentials.environmentId, input.environmentId), - eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), - isNull(relayEnvironmentCredentials.revokedAt), - notExists( - db - .select({ userId: relayEnvironmentLinks.userId }) - .from(relayEnvironmentLinks) - .where( - and( - eq(relayEnvironmentLinks.environmentId, input.environmentId), - eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), - isNull(relayEnvironmentLinks.revokedAt), - ), + )(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentCredentials.revokedAt), + notExists( + db + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.environmentId, input.environmentId), + eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentLinks.revokedAt), ), - ), + ), ), - ) - .returning({ - credentialId: relayEnvironmentCredentials.credentialId, - }); - return rows.length > 0; - }, - Effect.mapError((cause) => new EnvironmentCredentialRevokePersistenceError({ cause })), - ), + ), + ) + .returning({ + credentialId: relayEnvironmentCredentials.credentialId, + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentCredentialRevokePersistenceError({ + environmentId: input.environmentId, + cause, + }), + ), + ); + return rows.length > 0; + }), }); }); diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index 346daef44a6b..dccb9e39f60f 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -8,6 +8,76 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { + it.effect("retains link lookup failures with user and environment identity", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const error = yield* Effect.flip( + links.getForUser({ userId: "user-1", environmentId: "env-1" }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentLinkLookupPersistenceError", + userId: "user-1", + environmentId: "env-1", + }); + expect(error.cause).toBe(cause); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + + it.effect("identifies delivery-user list failures without retaining key material", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: () => Effect.fail(cause), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const error = yield* Effect.flip( + links.listDeliveryUsersForEnvironment({ + environmentId: "env-1", + environmentPublicKey: "sensitive-public-key-material", + }), + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentLinkUserListPersistenceError", + operation: "list-delivery-users", + environmentId: "env-1", + }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("environmentPublicKey"); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("selects users when either notifications or Live Activities are enabled", () => { const whereConditions: Array = []; const fakeDb = { diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index ee7019656cc2..6630af0a11bf 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -26,55 +26,78 @@ export interface AgentAwarenessDeliveryUserRecord { export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkUpsertPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + deviceId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist environment link"; + return `Failed to persist environment link for user '${this.userId}', environment '${this.environmentId}'`; } } export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkUserListPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["list-users", "list-delivery-users"]), + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list users linked to environment"; + return `Environment link user query '${this.operation}' failed for environment '${this.environmentId}'`; } } export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentPublicKeyListPersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list environment public keys"; + return `Failed to list public keys for environment '${this.environmentId}'`; } } export class EnvironmentLinkListPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list environment links"; + return `Failed to list environment links for user '${this.userId}'`; } } export class EnvironmentLinkLookupPersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkLookupPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to look up environment link"; + return `Failed to look up environment link for user '${this.userId}', environment '${this.environmentId}'`; } } export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorClass()( "EnvironmentLinkRevokePersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + environmentId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to revoke environment link"; + return `Failed to revoke environment link for user '${this.userId}', environment '${this.environmentId}'`; } } @@ -142,22 +165,37 @@ const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return EnvironmentLinks.of({ - upsert: Effect.fn("relay.environment_links.upsert")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.proof.environmentId, - }); - const now = DateTime.formatIso(yield* DateTime.now); - const { request, proof } = input; - const environmentId = proof.environmentId; - const { endpoint } = input; - yield* db - .insert(relayEnvironmentLinks) - .values({ - userId: input.userId, - environmentId, - environmentLabel: proof.descriptor.label, + upsert: Effect.fn("relay.environment_links.upsert")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.proof.environmentId, + }); + const now = DateTime.formatIso(yield* DateTime.now); + const { request, proof } = input; + const environmentId = proof.environmentId; + const { endpoint } = input; + yield* db + .insert(relayEnvironmentLinks) + .values({ + userId: input.userId, + environmentId, + environmentLabel: proof.descriptor.label, + environmentPublicKey: proof.environmentPublicKey, + endpointHttpBaseUrl: endpoint.httpBaseUrl, + endpointWsBaseUrl: endpoint.wsBaseUrl, + endpointProviderKind: endpoint.providerKind, + notificationsEnabled: request.notificationsEnabled, + liveActivitiesEnabled: request.liveActivitiesEnabled, + managedTunnelsEnabled: request.managedTunnelsEnabled, + createdByDeviceId: request.deviceId ?? null, + revokedAt: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], + set: { environmentPublicKey: proof.environmentPublicKey, + environmentLabel: proof.descriptor.label, endpointHttpBaseUrl: endpoint.httpBaseUrl, endpointWsBaseUrl: endpoint.wsBaseUrl, endpointProviderKind: endpoint.providerKind, @@ -166,28 +204,21 @@ const make = Effect.gen(function* () { managedTunnelsEnabled: request.managedTunnelsEnabled, createdByDeviceId: request.deviceId ?? null, revokedAt: null, - createdAt: now, updatedAt: now, - }) - .onConflictDoUpdate({ - target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], - set: { - environmentPublicKey: proof.environmentPublicKey, - environmentLabel: proof.descriptor.label, - endpointHttpBaseUrl: endpoint.httpBaseUrl, - endpointWsBaseUrl: endpoint.wsBaseUrl, - endpointProviderKind: endpoint.providerKind, - notificationsEnabled: request.notificationsEnabled, - liveActivitiesEnabled: request.liveActivitiesEnabled, - managedTunnelsEnabled: request.managedTunnelsEnabled, - createdByDeviceId: request.deviceId ?? null, - revokedAt: null, - updatedAt: now, - }, - }); - }, - Effect.mapError((cause) => new EnvironmentLinkUpsertPersistenceError({ cause })), - ), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkUpsertPersistenceError({ + userId: input.userId, + environmentId, + ...(request.deviceId === undefined ? {} : { deviceId: request.deviceId }), + cause, + }), + ), + ); + }), listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")( function* (input) { @@ -198,7 +229,14 @@ const make = Effect.gen(function* () { .where(agentAwarenessDeliveryUserCondition(input.environmentId)) .pipe( Effect.map((rows) => rows.map((row) => row.userId)), - Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkUserListPersistenceError({ + operation: "list-users", + environmentId: input.environmentId, + cause, + }), + ), ); }, ), @@ -223,7 +261,14 @@ const make = Effect.gen(function* () { liveActivitiesEnabled: row.liveActivitiesEnabled, })), ), - Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkUserListPersistenceError({ + operation: "list-delivery-users", + environmentId: input.environmentId, + cause, + }), + ), ); }), @@ -244,7 +289,13 @@ const make = Effect.gen(function* () { Effect.map((rows) => [ ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)), ]), - Effect.mapError((cause) => new EnvironmentPublicKeyListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentPublicKeyListPersistenceError({ + environmentId: input.environmentId, + cause, + }), + ), ); }), @@ -280,7 +331,13 @@ const make = Effect.gen(function* () { linkedAt: row.createdAt, })), ), - Effect.mapError((cause) => new EnvironmentLinkListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), @@ -328,34 +385,48 @@ const make = Effect.gen(function* () { } : null; }), - Effect.mapError((cause) => new EnvironmentLinkLookupPersistenceError({ cause })), + Effect.mapError( + (cause) => + new EnvironmentLinkLookupPersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), ); }), - revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.environmentId, - }); - const revokedAt = DateTime.formatIso(yield* DateTime.now); - const rows = yield* db - .update(relayEnvironmentLinks) - .set({ - revokedAt, - updatedAt: revokedAt, - }) - .where( - and( - eq(relayEnvironmentLinks.userId, input.userId), - eq(relayEnvironmentLinks.environmentId, input.environmentId), - isNull(relayEnvironmentLinks.revokedAt), - ), - ) - .returning({ environmentId: relayEnvironmentLinks.environmentId }); - return rows.length > 0; - }, - Effect.mapError((cause) => new EnvironmentLinkRevokePersistenceError({ cause })), - ), + revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentLinks) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + eq(relayEnvironmentLinks.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .returning({ environmentId: relayEnvironmentLinks.environmentId }) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkRevokePersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), + ); + return rows.length > 0; + }), }); }); diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 6061c6e81741..158bcec98038 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -108,6 +108,7 @@ describe("relay client authentication", () => { describe("relay environment authentication", () => { it.effect("preserves credential lookup persistence failures as internal errors", () => { const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ + stage: "lookup-credential", cause: "database unavailable", }); const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = { diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 33bcd187c26e..29e2026de3ca 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -238,13 +238,12 @@ export const relayEnvironmentAuthLayer = Layer.effect( { credential }, ) { const token = readHttpAuthorizationCredential(credential); - const principal = yield* credentials - .authenticate(token) - .pipe( - Effect.catchTag("EnvironmentCredentialAuthenticatePersistenceError", () => + const principal = yield* credentials.authenticate(token).pipe( + Effect.catchTags({ + EnvironmentCredentialAuthenticatePersistenceError: () => relayInternalErrorResponse("persistence_failed"), - ), - ); + }), + ); if (principal._tag === "None") { return yield* relayAuthInvalidError("not_authorized"); } @@ -777,17 +776,72 @@ export const serverApi = HttpApiBuilder.group( reason: "persistence_failed", traceId, }), - ApnsDeliveryJobQueuePayloadInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobLiveActivityAggregateMissing: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobLiveActivityNotificationUnexpected: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobPushNotificationMissing: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobPushNotificationAggregateUnexpected: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobCreatedAtInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobExpiresAtInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobTimeWindowInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobTimeWindowTooLong: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobSignatureInvalid: mapApnsDeliveryJobInternalError, - ApnsDeliveryJobExpired: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobQueuePayloadInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobLiveActivityAggregateMissing: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobLiveActivityNotificationUnexpected: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobPushNotificationMissing: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobPushNotificationAggregateUnexpected: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobCreatedAtInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobExpiresAtInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobTimeWindowInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobTimeWindowTooLong: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobSignatureInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobExpired: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), ApnsDeliveryJobClaimInFlight: (_error, traceId) => new RelayInternalError({ code: "internal_error", @@ -892,14 +946,6 @@ function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) { ): Effect.Effect, R> => effect.pipe(Effect.catch(mapError)); } -function mapApnsDeliveryJobInternalError(_error: unknown, traceId: string) { - return new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }); -} - type TaggedErrorTag = Extract["_tag"]; type MapErrorTagCases = { From c1d8a22f073e36768a8af5b0ef6472d802cbfa4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:28:14 -0700 Subject: [PATCH 05/80] [codex] Structure desktop persisted credential errors (#3239) Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 16 +- .../src/app/DesktopConnectionCatalogStore.ts | 195 +++++++++++++----- .../settings/DesktopSavedEnvironments.test.ts | 21 +- .../src/settings/DesktopSavedEnvironments.ts | 152 +++++++++++--- 4 files changed, 291 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index e0be7f39b39e..7c7818994f63 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -393,7 +393,21 @@ describe("DesktopConnectionCatalogStore", () => { assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); yield* Ref.set(failDecrypt, true); const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageDecryptError); + assert.instanceOf( + error, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, + ); + assert.equal(error.operation, "decrypt-catalog"); + assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError); + const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError; + assert.instanceOf(decryptError.cause, Error); + assert.equal(decryptError.cause.message, "invalid encrypted catalog"); + assert.equal( + error.message, + `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`, + ); + assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 8467fe3f0774..5ec2edb595ff 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -53,8 +53,6 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "write-temporary-file", "replace-catalog-file", ]); -type DesktopConnectionCatalogStoreWriteOperation = - typeof DesktopConnectionCatalogStoreWriteOperation.Type; const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ "read-legacy-registry", @@ -62,8 +60,12 @@ const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ "encode-catalog", "persist-catalog", ]); -type DesktopConnectionCatalogStoreMigrationOperation = - typeof DesktopConnectionCatalogStoreMigrationOperation.Type; + +const DesktopConnectionCatalogStoreProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-catalog", + "decrypt-catalog", +]); export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreWriteError", @@ -78,17 +80,6 @@ export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorC } } -const writeError = ( - operation: DesktopConnectionCatalogStoreWriteOperation, - path: string, - cause: unknown, -): DesktopConnectionCatalogStoreWriteError => - new DesktopConnectionCatalogStoreWriteError({ - operation, - path, - cause, - }); - export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", { @@ -142,18 +133,18 @@ export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedEr } } -const migrationError = ( - operation: DesktopConnectionCatalogStoreMigrationOperation, - catalogPath: string, - cause: unknown, - environmentId?: string, -): DesktopConnectionCatalogStoreMigrationError => - new DesktopConnectionCatalogStoreMigrationError({ - operation, - catalogPath, - ...(environmentId === undefined ? {} : { environmentId }), - cause, - }); +export class DesktopConnectionCatalogStoreProtectionError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreProtectionError", + { + operation: DesktopConnectionCatalogStoreProtectionOperation, + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop connection catalog protection failed during ${this.operation} at ${this.catalogPath}.`; + } +} export class DesktopConnectionCatalogStore extends Context.Service< DesktopConnectionCatalogStore, @@ -164,13 +155,13 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageError + | DesktopConnectionCatalogStoreProtectionError >; readonly set: ( catalog: string, ) => Effect.Effect< boolean, - DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError + DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError >; readonly clear: Effect.Effect; } @@ -236,20 +227,46 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")( const directory = input.path.dirname(input.catalogPath); const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe( - Effect.mapError((cause) => writeError("encode-document", input.catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "encode-document", + path: input.catalogPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), ); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); yield* Effect.gen(function* () { - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.catalogPath) - .pipe( - Effect.mapError((cause) => writeError("replace-catalog-file", input.catalogPath, cause)), - ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.catalogPath).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "replace-catalog-file", + path: input.catalogPath, + cause, + }), + ), + ); }).pipe( Effect.ensuring( input.fileSystem.remove(tempPath, { force: true }).pipe( @@ -330,13 +347,17 @@ const migrateSavedEnvironmentRecords = Effect.fn( wsBaseUrl: record.wsBaseUrl, }), ); - const token = yield* savedEnvironments - .getSecret(record.environmentId) - .pipe( - Effect.mapError((cause) => - migrationError("read-legacy-secret", catalogPath, cause, record.environmentId), - ), - ); + const token = yield* savedEnvironments.getSecret(record.environmentId).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-secret", + catalogPath, + environmentId: record.environmentId, + cause, + }), + ), + ); if (Option.isSome(token)) { credentials.push({ connectionId: id, @@ -362,13 +383,41 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); + const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "check-encryption-availability", + catalogPath, + cause, + }), + ), + ); const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( catalog: string, ) { - const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); + const encryptedCatalog = Encoding.encodeBase64( + yield* safeStorage.encryptString(catalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "encrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ); const suffix = (yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "create-temporary-file-name", + path: catalogPath, + cause, + }), + ), )).replace(/-/g, ""); yield* writeDocument({ fileSystem, @@ -380,21 +429,42 @@ export const make = Effect.gen(function* () { }); const migrateLegacyCatalog = Effect.gen(function* () { - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return Option.none(); } const records = yield* savedEnvironments.getRegistry.pipe( - Effect.mapError((cause) => migrationError("read-legacy-registry", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "read-legacy-registry", + catalogPath, + cause, + }), + ), ); if (records.length === 0) { return Option.none(); } const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath); const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe( - Effect.mapError((cause) => migrationError("encode-catalog", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "encode-catalog", + catalogPath, + cause, + }), + ), ); yield* writeCatalog(encoded).pipe( - Effect.mapError((cause) => migrationError("persist-catalog", catalogPath, cause)), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreMigrationError({ + operation: "persist-catalog", + catalogPath, + cause, + }), + ), ); return Option.some(encoded); }); @@ -405,16 +475,27 @@ export const make = Effect.gen(function* () { if (Option.isNone(document)) { return yield* migrateLegacyCatalog; } - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return Option.none(); } const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( - Effect.flatMap(safeStorage.decryptString), + Effect.flatMap((encryptedCatalog) => + safeStorage.decryptString(encryptedCatalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ), ); return Option.some(decrypted); }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { - if (!(yield* safeStorage.isEncryptionAvailable)) { + if (!(yield* encryptionAvailable)) { return false; } yield* writeCatalog(catalog); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index abd25a39f5bd..ec70308b3d34 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -271,10 +271,11 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("surfaces typed safe storage availability failures", () => { + it.effect("adds saved-environment context to safe storage availability failures", () => { const cause = new Error("safe storage unavailable"); return withSavedEnvironments( Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; yield* savedEnvironments.setRegistry([savedRegistryRecord]); @@ -285,8 +286,22 @@ describe("DesktopSavedEnvironments", () => { }) .pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); - assert.equal(error.cause, cause); + assert.instanceOf( + error, + DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError, + ); + assert.equal(error.operation, "check-encryption-availability"); + assert.equal(error.environmentId, savedRegistryRecord.environmentId); + assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); + assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + const availabilityError = + error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError; + assert.strictEqual(availabilityError.cause, cause); + assert.equal( + error.message, + `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, + ); + assert.notEqual(error.message, availabilityError.message); }), { availabilityError: cause }, ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 64c40d39f0e3..bdda7f9c7386 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -77,7 +77,12 @@ const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([ "write-temporary-file", "replace-registry-file", ]); -type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type; + +const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([ + "check-encryption-availability", + "encrypt-secret", + "decrypt-secret", +]); export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsWriteError", @@ -92,17 +97,6 @@ export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass< } } -const writeError = ( - operation: DesktopSavedEnvironmentsWriteOperation, - path: string, - cause: unknown, -): DesktopSavedEnvironmentsWriteError => - new DesktopSavedEnvironmentsWriteError({ - operation, - path, - cause, - }); - export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", { @@ -141,6 +135,20 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedError } } +export class DesktopSavedEnvironmentSecretProtectionError extends Schema.TaggedErrorClass()( + "DesktopSavedEnvironmentSecretProtectionError", + { + operation: DesktopSavedEnvironmentSecretProtectionOperation, + environmentId: Schema.String, + registryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop saved-environment secret protection failed during ${this.operation} for environment ${this.environmentId} at ${this.registryPath}.`; + } +} + export type DesktopSavedEnvironmentsReadRegistryError = | DesktopSavedEnvironmentsReadError | DesktopSavedEnvironmentsDocumentDecodeError; @@ -152,11 +160,11 @@ export type DesktopSavedEnvironmentsMutationError = export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageError; + | DesktopSavedEnvironmentSecretProtectionError; export type DesktopSavedEnvironmentsSetSecretError = | DesktopSavedEnvironmentsMutationError - | ElectronSafeStorage.ElectronSafeStorageError; + | DesktopSavedEnvironmentSecretProtectionError; export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, @@ -276,19 +284,45 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry const directory = input.path.dirname(input.registryPath); const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe( - Effect.mapError((cause) => writeError("encode-registry", input.registryPath, cause)), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "encode-registry", + path: input.registryPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.registryPath).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "replace-registry-file", + path: input.registryPath, + cause, + }), + ), ); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.registryPath) - .pipe( - Effect.mapError((cause) => writeError("replace-registry-file", input.registryPath, cause)), - ); }, ); @@ -341,8 +375,13 @@ export const make = Effect.gen(function* () { const writeDocument = (document: SavedEnvironmentRegistryDocument) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsWriteError({ + operation: "create-temporary-file-name", + path: environment.savedEnvironmentRegistryPath, + cause, + }), ), Effect.flatMap((suffix) => writeRegistryDocument({ @@ -396,7 +435,21 @@ export const make = Effect.gen(function* () { document.records.find((record) => record.environmentId === environmentId) ?.encryptedBearerToken, ); - if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + if (Option.isNone(encoded)) { + return Option.none(); + } + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { return Option.none(); } @@ -405,7 +458,19 @@ export const make = Effect.gen(function* () { environment.savedEnvironmentRegistryPath, encoded.value, ); - return Option.some(yield* safeStorage.decryptString(secretBytes)); + return Option.some( + yield* safeStorage.decryptString(secretBytes).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "decrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); }), setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { const { environmentId, secret } = input; @@ -415,11 +480,34 @@ export const make = Effect.gen(function* () { environment.savedEnvironmentRegistryPath, ); - if (!(yield* safeStorage.isEncryptionAvailable)) { + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "check-encryption-availability", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ); + if (!encryptionAvailable) { return false; } - const encryptedBearerToken = Encoding.encodeBase64(yield* safeStorage.encryptString(secret)); + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret).pipe( + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentSecretProtectionError({ + operation: "encrypt-secret", + environmentId, + registryPath: environment.savedEnvironmentRegistryPath, + cause, + }), + ), + ), + ); let found = false; const nextDocument: SavedEnvironmentRegistryDocument = { version: document.version, From bd8e3ee008e4c78085ebfbf47bf216389a92ac75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:29:24 -0700 Subject: [PATCH 06/80] Align text generation error catches (#3292) Co-authored-by: codex --- .../textGeneration/ClaudeTextGeneration.ts | 38 ++++++----- .../src/textGeneration/CodexTextGeneration.ts | 19 +++--- .../textGeneration/CursorTextGeneration.ts | 67 ++++++++----------- .../src/textGeneration/GrokTextGeneration.ts | 62 +++++++---------- .../textGeneration/OpenCodeTextGeneration.ts | 19 +++--- 5 files changed, 91 insertions(+), 114 deletions(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 872bf936cb19..453bb62b728e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -234,28 +234,30 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); const envelope = yield* decodeClaudeOutputEnvelope(rawStdout).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude CLI returned unexpected output format.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude CLI returned unexpected output format.", + cause, + }), + ), + }), ); const decodeOutput = Schema.decodeEffect(outputSchemaJson); return yield* decodeOutput(envelope.structured_output).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude returned invalid structured output.", + cause, + }), + ), + }), ); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 95783b06cca0..0e68994fd3dd 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -281,15 +281,16 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }), ), Effect.flatMap(decodeOutput), - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Codex returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Codex returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe(Effect.ensuring(cleanup)); }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 24676789b055..3e1f4eb8bbcf 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -28,30 +28,7 @@ import { const CURSOR_TIMEOUT_MS = 180_000; -function mapCursorAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} +const isTextGenerationError = Schema.is(TextGenerationError); /** * Build a Cursor text-generation closure bound to a specific `CursorSettings` @@ -111,13 +88,14 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu model: modelSelection.model, selections: modelSelection.options, mapError: ({ cause, configId, step }) => - mapCursorAcpError( + new TextGenerationError({ operation, - step === "set-config-option" - ? `Failed to set Cursor ACP config option "${configId}" for text generation.` - : "Failed to set Cursor ACP base model for text generation.", + detail: + step === "set-config-option" + ? `Failed to set Cursor ACP config option "${configId}" for text generation.` + : "Failed to set Cursor ACP base model for text generation.", cause, - ), + }), }); return yield* runtime.prompt({ @@ -140,7 +118,11 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), + : new TextGenerationError({ + operation, + detail: "Cursor ACP request failed.", + cause, + }), ), ); @@ -157,21 +139,26 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawResult)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe( Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), + : new TextGenerationError({ + operation, + detail: "Cursor ACP text generation failed.", + cause, + }), ), Effect.scoped, ); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index ab52efb1116d..1bb582163056 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -31,30 +31,7 @@ import { const GROK_TIMEOUT_MS = 180_000; -function mapGrokAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} +const isTextGenerationError = Schema.is(TextGenerationError); export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( grokSettings: GrokSettings, @@ -109,11 +86,11 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), requestedModelId: resolvedModel, mapError: (cause) => - mapGrokAcpError( + new TextGenerationError({ operation, - "Failed to set Grok ACP base model for text generation.", + detail: "Failed to set Grok ACP base model for text generation.", cause, - ), + }), }); return yield* runtime.prompt({ @@ -133,7 +110,11 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => isTextGenerationError(cause) ? cause - : mapGrokAcpError(operation, "Grok ACP request failed.", cause), + : new TextGenerationError({ + operation, + detail: "Grok ACP request failed.", + cause, + }), ), ); @@ -150,21 +131,26 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(trimmed)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Grok Agent returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Grok Agent returned invalid structured output.", + cause, + }), + ), + }), ); }).pipe( Effect.mapError((cause) => isTextGenerationError(cause) ? cause - : mapGrokAcpError(operation, "Grok ACP text generation failed.", cause), + : new TextGenerationError({ + operation, + detail: "Grok ACP text generation failed.", + cause, + }), ), Effect.scoped, ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 0ba7726d68ce..f59e76942134 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -348,15 +348,16 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + }), ); }); From d7ff7e73336482d64449f72a4bd31d9ce9f4c01e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:30:03 -0700 Subject: [PATCH 07/80] [codex] Audit managed endpoint error context (#3245) Co-authored-by: codex --- .../ManagedEndpointAllocations.test.ts | 93 ++++ .../ManagedEndpointAllocations.ts | 133 ++++- .../ManagedEndpointProvider.test.ts | 88 +++- .../environments/ManagedEndpointProvider.ts | 468 +++++++++++++++--- 4 files changed, 678 insertions(+), 104 deletions(-) create mode 100644 infra/relay/src/environments/ManagedEndpointAllocations.test.ts diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts new file mode 100644 index 000000000000..1a3c01d1e13e --- /dev/null +++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as RelayDb from "../db.ts"; +import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; + +const layerWithDb = (db: RelayDb.RelayDb["Service"]) => + ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); + +describe("ManagedEndpointAllocations", () => { + it.effect("retains database failures with allocation operation and identity", () => { + const cause = new Error("database unavailable"); + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + where: () => ({ + limit: () => Effect.fail(cause), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const error = yield* Effect.flip( + allocations.get({ userId: "user-1", environmentId: "environment-1" }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointAllocationPersistenceError", + operation: "get", + stage: "database-request", + userId: "user-1", + environmentId: "environment-1", + }); + expect(error.cause).toBe(cause); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); + + it.effect("reports an unresolved reservation without manufacturing a cause", () => { + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Effect.succeed([]), + }), + }), + }; + }, + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayManagedEndpointAllocations); + return { + where: () => ({ + limit: () => Effect.succeed([]), + }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const error = yield* Effect.flip( + allocations.reserve({ + userId: "user-1", + environmentId: "environment-1", + hostname: "environment-1.example.test", + tunnelName: "environment-1-tunnel", + }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointAllocationPersistenceError", + operation: "reserve", + stage: "resolve-reservation", + userId: "user-1", + environmentId: "environment-1", + hostname: "environment-1.example.test", + tunnelName: "environment-1-tunnel", + }); + expect(error.cause).toBeUndefined(); + expect(error.message).toContain("'resolve-reservation'"); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); +}); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 440f1d48dc37..c951ee03c8d1 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -38,15 +38,29 @@ export function resolveReadyManagedEndpoint(input: { export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErrorClass()( "ManagedEndpointAllocationPersistenceError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals([ + "get", + "reserve", + "record-tunnel", + "record-dns", + "mark-ready", + "remove", + ]), + stage: Schema.Literals(["database-request", "resolve-reservation"]), + userId: Schema.String, + environmentId: Schema.String, + hostname: Schema.optionalKey(Schema.String), + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, ) { override get message(): string { - return "Failed to persist managed endpoint allocation"; + return `Managed endpoint allocation '${this.operation}' failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } -const isManagedEndpointAllocationPersistenceError = Schema.is( - ManagedEndpointAllocationPersistenceError, -); interface ManagedEndpointAllocationKey { readonly userId: string; @@ -106,11 +120,6 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) => eq(relayManagedEndpointAllocations.environmentId, input.environmentId), ); -const persistenceError = (cause: unknown) => - isManagedEndpointAllocationPersistenceError(cause) - ? cause - : new ManagedEndpointAllocationPersistenceError({ cause }); - const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; @@ -125,7 +134,15 @@ const make = Effect.gen(function* () { .limit(1) .pipe( Effect.map((rows) => rows[0] ?? null), - Effect.mapError(persistenceError), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "get", + stage: "database-request", + ...input, + cause, + }), + ), ); }), reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* ( @@ -140,7 +157,18 @@ const make = Effect.gen(function* () { updatedAt: now, }) .onConflictDoNothing() - .returning(allocationSelection); + .returning(allocationSelection) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "reserve", + stage: "database-request", + ...input, + cause, + }), + ), + ); const allocation = inserted[0] ?? @@ -149,16 +177,29 @@ const make = Effect.gen(function* () { .from(relayManagedEndpointAllocations) .where(whereAllocation(input)) .limit(1) - .pipe(Effect.map((rows) => rows[0]))); + .pipe( + Effect.map((rows) => rows[0]), + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "reserve", + stage: "database-request", + ...input, + cause, + }), + ), + )); if (allocation === undefined) { return yield* new ManagedEndpointAllocationPersistenceError({ - cause: new Error("Managed endpoint allocation was not persisted."), + operation: "reserve", + stage: "resolve-reservation", + ...input, }); } return allocation; - }, Effect.mapError(persistenceError)), + }), recordTunnel: Effect.fn("relay.managed_endpoint_allocations.record_tunnel")(function* ( input: RecordManagedEndpointTunnelInput, ) { @@ -168,8 +209,19 @@ const make = Effect.gen(function* () { tunnelId: input.tunnelId, updatedAt: DateTime.formatIso(yield* DateTime.now), }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "record-tunnel", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), recordDns: Effect.fn("relay.managed_endpoint_allocations.record_dns")(function* ( input: RecordManagedEndpointDnsInput, ) { @@ -179,8 +231,19 @@ const make = Effect.gen(function* () { dnsRecordId: input.dnsRecordId, updatedAt: DateTime.formatIso(yield* DateTime.now), }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "record-dns", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), markReady: Effect.fn("relay.managed_endpoint_allocations.mark_ready")(function* ( input: ManagedEndpointAllocationKey, ) { @@ -191,13 +254,37 @@ const make = Effect.gen(function* () { readyAt: now, updatedAt: now, }) - .where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "mark-ready", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( input: ManagedEndpointAllocationKey, ) { - yield* db.delete(relayManagedEndpointAllocations).where(whereAllocation(input)); - }, Effect.mapError(persistenceError)), + yield* db + .delete(relayManagedEndpointAllocations) + .where(whereAllocation(input)) + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointAllocationPersistenceError({ + operation: "remove", + stage: "database-request", + ...input, + cause, + }), + ), + ); + }), }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 7b8f0cc28677..479be4123805 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -130,6 +130,9 @@ function makeDnsClient( calls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); if (!currentRecords.some((record) => record.id === dnsRecordId)) { return yield* new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "update-record", + hostname: request.name, + dnsRecordId, cause: `DNS record ${dnsRecordId} does not exist.`, }); } @@ -398,7 +401,13 @@ describe("ManagedEndpointProvider", () => { expect(dnsCalls).toHaveLength(0); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { - expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); + expect(result.failure).toMatchObject({ + _tag: "ManagedEndpointOriginNotAllowed", + userId: "user_ABC", + environmentId: "env_ABC", + host: "192.168.1.10", + port: 3773, + }); } }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); }); @@ -593,6 +602,8 @@ describe("ManagedEndpointProvider", () => { const tunnelCalls: TunnelCall[] = []; let deleteAttempts = 0; const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId: "tunnel-id", cause: "Cloudflare tunnel deletion failed", }); const tunnels = makePersistentTunnelClient(tunnelCalls); @@ -618,6 +629,16 @@ describe("ManagedEndpointProvider", () => { }); const first = yield* Effect.result(provider.deprovision(key)); expect(first._tag).toBe("Failure"); + if (first._tag === "Failure") { + expect(first.failure).toMatchObject({ + _tag: "ManagedEndpointDeprovisioningFailed", + stage: "delete-tunnel", + userId: key.userId, + environmentId: key.environmentId, + tunnelId: "tunnel-id", + }); + expect(first.failure.cause).toBe(failure); + } yield* provider.deprovision(key); expect(allocationCalls.map((call) => call.operation)).toEqual([ @@ -639,13 +660,23 @@ describe("ManagedEndpointProvider", () => { ...makeTunnelClient(), delete: () => Effect.fail( - new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause: notFound }), + new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId: "tunnel-id", + cause: notFound, + }), ), }); const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ ...makeDnsClient(), deleteRecord: () => - Effect.fail(new ManagedEndpointProvider.ManagedEndpointDnsClientError({ cause: notFound })), + Effect.fail( + new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId: "created-record-id", + cause: notFound, + }), + ), }); const layer = providerLayer(tunnelClient, dnsClient, makeAllocations(allocationCalls)); @@ -690,6 +721,8 @@ describe("ManagedEndpointProvider", () => { it.effect("recovers when DNS creation reports failure after the record became visible", () => { const dnsCalls: DnsCall[] = []; const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: expectedManagedHostname("env_ABC"), cause: "ambiguous Cloudflare DNS response", }); let records: ReadonlyArray<{ readonly id: string }> = []; @@ -732,8 +765,43 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); + it.effect("reports malformed tunnel responses without manufacturing a cause", () => { + const dnsCalls: DnsCall[] = []; + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...makeTunnelClient(), + create: () => Effect.succeed({ id: "returned-tunnel-id", name: null }), + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const error = yield* Effect.flip( + provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "validate-tunnel-response", + userId: "user_ABC", + environmentId: "env_ABC", + hostname: expectedManagedHostname("env_ABC"), + tunnelName: expectedManagedTunnelName("env_ABC"), + returnedTunnelId: "returned-tunnel-id", + }); + if (error._tag === "ManagedEndpointProvisioningFailed") { + expect(error.cause).toBeUndefined(); + } + expect(dnsCalls).toHaveLength(0); + }).pipe(Effect.provide(providerLayer(tunnelClient, makeDnsClient(dnsCalls)))); + }); + it.effect("fails provisioning when the DNS client fails", () => { const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "list-records", + hostname: expectedManagedHostname("env_ABC"), cause: "Cloudflare DNS failure", }); const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ @@ -753,8 +821,18 @@ describe("ManagedEndpointProvider", () => { }), ); - expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); - expect(error.cause).toBe(failure); + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "ensure-dns-record", + userId: "user_ABC", + environmentId: "env_ABC", + hostname: expectedManagedHostname("env_ABC"), + tunnelName: expectedManagedTunnelName("env_ABC"), + tunnelId: "tunnel-id", + }); + if (error._tag === "ManagedEndpointProvisioningFailed") { + expect(error.cause).toBe(failure); + } }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 2de9d1966ace..bb2dd4b0ce9a 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -7,7 +7,6 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { @@ -27,40 +26,86 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass()( "ManagedEndpointProvisioningNotConfigured", - {}, + { + userId: Schema.String, + environmentId: Schema.String, + missingSettings: Schema.Array( + Schema.Literals(["managedEndpointBaseDomain", "managedEndpointNamespace"]), + ), + }, ) { override get message(): string { - return "Managed endpoint provisioning is not configured"; + return `Managed endpoint provisioning is not configured for user '${this.userId}', environment '${this.environmentId}': missing ${this.missingSettings.join(", ")}`; } } +const ManagedEndpointProvisioningStage = Schema.Literals([ + "derive-environment-hash", + "reserve-allocation", + "ensure-tunnel", + "validate-tunnel-response", + "record-tunnel", + "configure-tunnel", + "ensure-dns-record", + "record-dns", + "get-tunnel-token", + "mark-allocation-ready", +]); + export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass()( "ManagedEndpointProvisioningFailed", - { cause: Schema.Defect() }, + { + stage: ManagedEndpointProvisioningStage, + userId: Schema.String, + environmentId: Schema.String, + hostname: Schema.optionalKey(Schema.String), + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + returnedTunnelName: Schema.optionalKey(Schema.String), + returnedTunnelId: Schema.optionalKey(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, ) { override get message(): string { - return "Managed endpoint provisioning failed"; + return `Managed endpoint provisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } +const ManagedEndpointDeprovisioningStage = Schema.Literals([ + "load-allocation", + "delete-dns-record", + "delete-tunnel", + "remove-allocation", +]); + export class ManagedEndpointDeprovisioningFailed extends Schema.TaggedErrorClass()( "ManagedEndpointDeprovisioningFailed", - { cause: Schema.Defect() }, + { + stage: ManagedEndpointDeprovisioningStage, + userId: Schema.String, + environmentId: Schema.String, + tunnelId: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint deprovisioning failed"; + return `Managed endpoint deprovisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`; } } export class ManagedEndpointOriginNotAllowed extends Schema.TaggedErrorClass()( "ManagedEndpointOriginNotAllowed", { + userId: Schema.String, + environmentId: Schema.String, host: Schema.String, port: Schema.Number, }, ) { override get message(): string { - return `Managed endpoint origin '${this.host}:${this.port}' is not allowed`; + return `Managed endpoint origin '${this.host}:${this.port}' is not allowed for user '${this.userId}', environment '${this.environmentId}'`; } } @@ -94,12 +139,26 @@ interface ManagedEndpointTunnel { readonly name?: string | null; } +const ManagedEndpointTunnelClientOperation = Schema.Literals([ + "list", + "create", + "put-configuration", + "get-token", + "delete", +]); + export class ManagedEndpointTunnelClientError extends Schema.TaggedErrorClass()( "ManagedEndpointTunnelClientError", - { cause: Schema.Defect() }, + { + operation: ManagedEndpointTunnelClientOperation, + tunnelName: Schema.optionalKey(Schema.String), + tunnelId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint tunnel provider request failed"; + const target = this.tunnelId ?? this.tunnelName; + return `Managed endpoint tunnel provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`; } } @@ -147,12 +206,25 @@ interface ManagedEndpointCnameRecordInput { readonly proxied: true; } +const ManagedEndpointDnsClientOperation = Schema.Literals([ + "list-records", + "create-record", + "update-record", + "delete-record", +]); + export class ManagedEndpointDnsClientError extends Schema.TaggedErrorClass()( "ManagedEndpointDnsClientError", - { cause: Schema.Defect() }, + { + operation: ManagedEndpointDnsClientOperation, + hostname: Schema.optionalKey(Schema.String), + dnsRecordId: Schema.optionalKey(Schema.String), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Managed endpoint DNS provider request failed"; + const target = this.dnsRecordId ?? this.hostname; + return `Managed endpoint DNS provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`; } } @@ -183,13 +255,26 @@ export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) => const requireCloudflareSettings = Effect.fnUntraced(function* ( settings: RelayConfiguration.RelayConfiguration["Service"], + input: { readonly userId: string; readonly environmentId: string }, ) { - if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) { - return yield* new ManagedEndpointProvisioningNotConfigured(); + const baseDomain = settings.managedEndpointBaseDomain; + const namespace = settings.managedEndpointNamespace; + const missingSettings: Array<"managedEndpointBaseDomain" | "managedEndpointNamespace"> = []; + if (!baseDomain) { + missingSettings.push("managedEndpointBaseDomain"); + } + if (!namespace) { + missingSettings.push("managedEndpointNamespace"); + } + if (!baseDomain || !namespace) { + return yield* new ManagedEndpointProvisioningNotConfigured({ + ...input, + missingSettings, + }); } return { - baseDomain: settings.managedEndpointBaseDomain, - namespace: settings.managedEndpointNamespace, + baseDomain, + namespace, }; }); @@ -231,10 +316,19 @@ function isNotFoundCause(cause: unknown): boolean { return "cause" in cause && isNotFoundCause(cause.cause); } -const ignoreNotFound = (effect: Effect.Effect): Effect.Effect => +type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError; + +const ignoreNotFound = ( + effect: Effect.Effect, +): Effect.Effect => effect.pipe( Effect.asVoid, - Effect.catch((cause) => (isNotFoundCause(cause) ? Effect.void : Effect.fail(cause))), + Effect.catchTags({ + ManagedEndpointTunnelClientError: (error) => + isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error), + ManagedEndpointDnsClientError: (error) => + isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error), + }), ); const make = Effect.gen(function* () { @@ -289,25 +383,26 @@ const make = Effect.gen(function* () { } return yield* dns.createRecord(dnsRecord).pipe( Effect.map((record) => record.id), - Effect.catch((createError) => - Effect.gen(function* () { - let records = yield* dns.listRecords(hostname); - for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { - yield* Effect.sleep("200 millis"); - records = yield* dns.listRecords(hostname); - } - return records; - }).pipe( - Effect.flatMap((records) => - records.length > 0 - ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) - : Effect.fail(createError), - ), - Effect.flatMap((dnsRecordId) => - dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), + Effect.catchTags({ + ManagedEndpointDnsClientError: (createError) => + Effect.gen(function* () { + let records = yield* dns.listRecords(hostname); + for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { + yield* Effect.sleep("200 millis"); + records = yield* dns.listRecords(hostname); + } + return records; + }).pipe( + Effect.flatMap((records) => + records.length > 0 + ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) + : Effect.fail(createError), + ), + Effect.flatMap((dnsRecordId) => + dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), + ), ), - ), - ), + }), ); }); @@ -317,25 +412,59 @@ const make = Effect.gen(function* () { "relay.user_id": input.userId, "relay.environment_id": input.environmentId, }); - const allocation = yield* allocations - .get(input) - .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + const allocation = yield* allocations.get(input).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "load-allocation", + cause, + }), + ), + ); if (allocation === null) { return; } - if (allocation.dnsRecordId !== null) { - yield* ignoreNotFound(dns.deleteRecord(allocation.dnsRecordId)).pipe( - Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + const dnsRecordId = allocation.dnsRecordId; + if (dnsRecordId !== null) { + yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "delete-dns-record", + dnsRecordId, + cause, + }), + ), ); } - if (allocation.tunnelId !== null) { - yield* ignoreNotFound(tunnels.delete(allocation.tunnelId)).pipe( - Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + const tunnelId = allocation.tunnelId; + if (tunnelId !== null) { + yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "delete-tunnel", + tunnelId, + cause, + }), + ), ); } - yield* allocations - .remove(input) - .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + yield* allocations.remove(input).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDeprovisioningFailed({ + ...input, + stage: "remove-allocation", + ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }), + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), + ); }), provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -346,11 +475,13 @@ const make = Effect.gen(function* () { }); if (!isLoopbackOrigin(input.origin)) { return yield* new ManagedEndpointOriginNotAllowed({ + userId: input.userId, + environmentId: input.environmentId, host: input.origin.localHttpHost, port: input.origin.localHttpPort, }); } - const cf = yield* requireCloudflareSettings(config); + const cf = yield* requireCloudflareSettings(config, input); const environmentHash = yield* crypto .digest( "SHA-256", @@ -360,19 +491,45 @@ const make = Effect.gen(function* () { ) .pipe( Effect.map(Encoding.encodeHex), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "derive-environment-hash", + cause, + }), + ), ); + const requestedHostname = managedEndpointHostname( + cf.namespace, + cf.baseDomain, + environmentHash, + ); + const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash); const allocation = yield* allocations .reserve({ userId: input.userId, environmentId: input.environmentId, - hostname: managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash), - tunnelName: managedEndpointTunnelName(cf.namespace, environmentHash), + hostname: requestedHostname, + tunnelName: requestedTunnelName, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "reserve-allocation", + hostname: requestedHostname, + tunnelName: requestedTunnelName, + cause, + }), + ), + ); const { hostname, tunnelName } = allocation; - const tunnel = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( + const tunnelResponse = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( Effect.map((tunnels) => tunnels.result), Effect.map(Arr.findFirst((tunnel) => tunnel.name === tunnelName)), Effect.flatMap( @@ -381,20 +538,50 @@ const make = Effect.gen(function* () { onNone: () => tunnels.create({ name: tunnelName, configSrc: "cloudflare" }), }), ), - Effect.filterMapOrFail((tunnel) => - tunnel.id && tunnel.name - ? Result.succeed({ id: tunnel.id, name: tunnel.name }) - : Result.fail(new ManagedEndpointProvisioningFailed({ cause: tunnel })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "ensure-tunnel", + hostname, + tunnelName, + cause, + }), ), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), ); + if (!tunnelResponse.id || !tunnelResponse.name) { + return yield* new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "validate-tunnel-response", + hostname, + tunnelName, + ...(tunnelResponse.id ? { returnedTunnelId: tunnelResponse.id } : {}), + ...(tunnelResponse.name ? { returnedTunnelName: tunnelResponse.name } : {}), + }); + } + const tunnel = { id: tunnelResponse.id, name: tunnelResponse.name }; yield* allocations .recordTunnel({ userId: input.userId, environmentId: input.environmentId, tunnelId: tunnel.id, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "record-tunnel", + hostname, + tunnelName, + tunnelId: tunnel.id, + cause, + }), + ), + ); yield* tunnels .putConfiguration(tunnel.id, { @@ -406,7 +593,20 @@ const make = Effect.gen(function* () { { service: "http_status:404" }, ], }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "configure-tunnel", + hostname, + tunnelName, + tunnelId: tunnel.id, + cause, + }), + ), + ); const dnsRecord = { type: "CNAME", @@ -417,7 +617,19 @@ const make = Effect.gen(function* () { } as const; const dnsRecordId = yield* ensureDnsRecord(hostname, allocation.dnsRecordId, dnsRecord).pipe( - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "ensure-dns-record", + hostname, + tunnelName, + tunnelId: tunnel.id, + ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), + cause, + }), + ), ); yield* allocations .recordDns({ @@ -425,17 +637,57 @@ const make = Effect.gen(function* () { environmentId: input.environmentId, dnsRecordId, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "record-dns", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); - const connectorToken = yield* tunnels - .getToken(tunnel.id) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + const connectorToken = yield* tunnels.getToken(tunnel.id).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "get-tunnel-token", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); yield* allocations .markReady({ userId: input.userId, environmentId: input.environmentId, }) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "mark-allocation-ready", + hostname, + tunnelName, + tunnelId: tunnel.id, + dnsRecordId, + cause, + }), + ), + ); return { endpoint: managedEndpointForHostname(hostname), @@ -464,27 +716,62 @@ export const layerCloudflareBindings = ( ManagedEndpointTunnelClient.of({ list: (request) => tunnelClient.list(request).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "list", + tunnelName: request.name, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), create: (request) => tunnelClient.create(request).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "create", + tunnelName: request.name, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), putConfiguration: (tunnelId, config) => tunnelClient.putConfiguration(tunnelId, config).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "put-configuration", + tunnelId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), getToken: (tunnelId) => tunnelClient.getToken(tunnelId).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "get-token", + tunnelId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), delete: (tunnelId) => tunnelClient.delete(tunnelId).pipe( - Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), }), @@ -500,23 +787,52 @@ export const layerCloudflareBindings = ( normalizeHostname(record.name) === normalizeHostname(hostname), ), ), - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "list-records", + hostname, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), createRecord: (request) => dnsClient.createDnsRecord(request).pipe( Effect.map((response) => ({ id: response.id })), - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: request.name, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), updateRecord: (dnsRecordId, request) => dnsClient.updateDnsRecord(dnsRecordId, request).pipe( - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "update-record", + hostname: request.name, + dnsRecordId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), deleteRecord: (dnsRecordId) => dnsClient.deleteDnsRecord(dnsRecordId).pipe( - Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId, + cause, + }), + ), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), }), From 569b3e5959c19e87fec28480c40011eae2e465dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:31:49 -0700 Subject: [PATCH 08/80] [codex] Structure desktop SSH prompt presentation failures (#3429) Co-authored-by: codex --- .../src/ssh/DesktopSshEnvironment.test.ts | 1 + .../src/ssh/DesktopSshPasswordPrompts.test.ts | 111 ++++++++++++- .../src/ssh/DesktopSshPasswordPrompts.ts | 156 ++++++++++++++---- 3 files changed, 230 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index baed2610286f..1fe2b86aae7b 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -23,6 +23,7 @@ describe("sshEnvironment", () => { const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ requestId: "prompt-1", destination: "devbox", + operation: "send-prompt-request", cause: new Error("renderer send failed"), }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index f0b5b1bd8ef7..5ec7dd65d1e2 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -17,7 +17,13 @@ interface SentMessage { readonly args: readonly unknown[]; } -function makeTestWindow() { +function makeTestWindow( + options: { + readonly isDestroyedError?: unknown; + readonly isMinimizedError?: unknown; + readonly sendError?: unknown; + } = {}, +) { const listeners = new Map void>>(); const sentMessages: SentMessage[] = []; let destroyed = false; @@ -26,8 +32,18 @@ function makeTestWindow() { let focused = false; const window = { - isDestroyed: () => destroyed, - isMinimized: () => minimized, + isDestroyed: () => { + if (options.isDestroyedError !== undefined) { + throw options.isDestroyedError; + } + return destroyed; + }, + isMinimized: () => { + if (options.isMinimizedError !== undefined) { + throw options.isMinimizedError; + } + return minimized; + }, restore: () => { restored = true; minimized = false; @@ -45,7 +61,11 @@ function makeTestWindow() { }, webContents: { send: (channel: string, ...args: readonly unknown[]) => { - sentMessages.push({ channel, args }); + const message = { channel, args }; + sentMessages.push(message); + if (options.sendError !== undefined) { + throw options.sendError; + } }, }, }; @@ -55,6 +75,7 @@ function makeTestWindow() { sentMessages, isRestored: () => restored, isFocused: () => focused, + closedListenerCount: () => listeners.get("closed")?.size ?? 0, close: () => { destroyed = true; const closedListeners = [...(listeners.get("closed") ?? [])]; @@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => { }) .pipe(Effect.forkScoped); + yield* Effect.yieldNow; yield* Effect.yieldNow; assert.equal(testWindow.sentMessages.length, 1); const sent = testWindow.sentMessages[0]; @@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(error.destination, "devbox"); }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); }); + + it.effect("cleans up a prompt that fails during renderer delivery", () => { + const cause = new Error("renderer unavailable"); + const testWindow = makeTestWindow({ sendError: cause }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "send-prompt-request"); + assert.equal(error.destination, "devbox"); + const requestId = error.requestId; + if (requestId === null) { + assert.fail("renderer delivery failures must retain their request id"); + } + assert.equal(testWindow.closedListenerCount(), 0); + + const resolveError = yield* prompts + .resolve({ requestId, password: "secret" }) + .pipe(Effect.flip); + assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("keeps a submitted password when a later presentation step fails", () => { + const testWindow = makeTestWindow({ + isMinimizedError: new Error("failed to read minimized state"), + }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const requestFiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + const sent = testWindow.sentMessages[0]; + assert.ok(sent); + const request = sent.args[0] as { readonly requestId: string }; + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + const password = yield* Fiber.join(requestFiber); + + assert.equal(password, "secret"); + assert.equal(testWindow.isFocused(), false); + assert.equal(testWindow.closedListenerCount(), 0); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("classifies a failed initial window availability check", () => { + const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "check-window-before-request"); + assert.equal(error.requestId, null); + assert.deepEqual(testWindow.sentMessages, []); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index c933bca3cb00..aa25d8135c7a 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; type DesktopSshPasswordPromptResolutionInput = typeof DesktopSshPasswordPromptResolutionInputSchema.Type; -const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; +const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([ + "before-request", + "before-presentation", + "after-send", + "after-restore", +]); + +const DesktopSshPromptPresentationOperation = Schema.Literals([ + "check-window-before-request", + "check-window-before-presentation", + "register-window-close-listener", + "send-prompt-request", + "check-window-after-send", + "check-window-minimized", + "restore-window", + "check-window-after-restore", + "focus-window", + "remove-window-close-listener", +]); +type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass()( "DesktopSshPromptRequestIdGenerationError", @@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl "DesktopSshPromptWindowUnavailableError", { destination: Schema.String, + requestId: Schema.NullOr(Schema.String), + stage: DesktopSshPromptWindowAvailabilityStage, }, ) { override get message(): string { - return WINDOW_UNAVAILABLE_MESSAGE; + const request = this.requestId === null ? "before a request id was assigned" : this.requestId; + return `T3 Code window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`; } } -const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError); - export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass()( "DesktopSshPromptPresentationError", { - requestId: Schema.String, + requestId: Schema.NullOr(Schema.String), destination: Schema.String, + operation: DesktopSshPromptPresentationOperation, cause: Schema.Defect(), }, ) { @@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( "desktop.sshPasswordPrompts.request", )(function* (input) { const window = yield* electronWindow.main; - if (Option.isNone(window) || window.value.isDestroyed()) { + if (Option.isNone(window)) { return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId: null, + stage: "before-request", + }); + } + + const unavailableBeforeRequest = yield* Effect.try({ + try: () => window.value.isDestroyed(), + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId: null, + destination: input.destination, + operation: "check-window-before-request", + cause, + }), + }); + if (unavailableBeforeRequest) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId: null, + stage: "before-request", }); } @@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( ), ); }; - const cleanup = Effect.sync(() => { + const runPresentationOperation = ( + operation: DesktopSshPromptPresentationOperation, + evaluate: () => A, + ) => + Effect.try({ + try: evaluate, + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId, + destination: input.destination, + operation, + cause, + }), + }); + const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( @@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( }), ), ); + const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) => + Deferred.poll(deferred).pipe( + Effect.flatMap( + Option.match({ + onSome: (completion) => completion, + onNone: () => + Ref.get(pendingRef).pipe( + Effect.flatMap((entries) => + entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred), + ), + ), + }), + ), + ); - return yield* Effect.try({ - try: () => { - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ - destination: input.destination, - }); - } - window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ + return yield* Effect.gen(function* () { + const unavailableBeforePresentation = yield* runPresentationOperation( + "check-window-before-presentation", + () => window.value.isDestroyed(), + ); + if (unavailableBeforePresentation) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId, + stage: "before-presentation", + }); + } + yield* runPresentationOperation("register-window-close-listener", () => + window.value.once("closed", cancelOnWindowClosed), + ); + return yield* Effect.gen(function* () { + yield* runPresentationOperation("send-prompt-request", () => + window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest), + ); + yield* Effect.yieldNow; + const unavailableAfterSend = yield* runPresentationOperation( + "check-window-after-send", + () => window.value.isDestroyed(), + ); + if (unavailableAfterSend) { + return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId, + stage: "after-send", }); } - if (window.value.isMinimized()) { - window.value.restore(); + const minimized = yield* runPresentationOperation("check-window-minimized", () => + window.value.isMinimized(), + ); + if (minimized) { + yield* runPresentationOperation("restore-window", () => window.value.restore()); } - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ + const unavailableAfterRestore = yield* runPresentationOperation( + "check-window-after-restore", + () => window.value.isDestroyed(), + ); + if (unavailableAfterRestore) { + return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId, + stage: "after-restore", }); } - window.value.focus(); - }, - catch: (cause) => - isDesktopSshPromptWindowUnavailableError(cause) - ? cause - : new DesktopSshPromptPresentationError({ - requestId, - destination: input.destination, - cause, - }), - }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); + yield* runPresentationOperation("focus-window", () => window.value.focus()); + return yield* waitForPassword; + }).pipe(Effect.catch(preferSubmittedPassword)); + }).pipe(Effect.ensuring(cleanup)); }); return DesktopSshPasswordPrompts.of({ From 630df6b986f6e254d3c564da639de5b7b52fe95d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:32:34 -0700 Subject: [PATCH 09/80] [codex] Enrich source-control errors (#3248) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 16 ++ .../src/sourceControl/AzureDevOpsCli.test.ts | 23 +++ .../src/sourceControl/AzureDevOpsCli.ts | 128 ++++++++------ .../AzureDevOpsSourceControlProvider.test.ts | 40 +++++ .../AzureDevOpsSourceControlProvider.ts | 130 +++++++++++--- .../BitbucketSourceControlProvider.test.ts | 39 +++++ .../BitbucketSourceControlProvider.ts | 121 +++++++++++--- .../src/sourceControl/GitHubCli.test.ts | 25 +-- apps/server/src/sourceControl/GitHubCli.ts | 126 ++++++++------ .../GitHubSourceControlProvider.test.ts | 43 +++++ .../GitHubSourceControlProvider.ts | 158 ++++++++++++++---- .../src/sourceControl/GitLabCli.test.ts | 23 +-- apps/server/src/sourceControl/GitLabCli.ts | 147 +++++++++------- .../GitLabSourceControlProvider.test.ts | 44 +++++ .../GitLabSourceControlProvider.ts | 132 ++++++++++++--- .../SourceControlProvider.test.ts | 19 +++ .../sourceControl/SourceControlProvider.ts | 30 ++++ .../SourceControlProviderRegistry.test.ts | 70 ++++++-- .../SourceControlProviderRegistry.ts | 105 ++++++++---- packages/contracts/src/sourceControl.ts | 4 + 20 files changed, 1084 insertions(+), 339 deletions(-) create mode 100644 apps/server/src/sourceControl/SourceControlProvider.test.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index d1cfc7d2a13c..14490765a9d0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -173,6 +173,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { } throw new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd, detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, }); } @@ -480,6 +482,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ? error : new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: input.cwd, detail: error instanceof Error ? `Failed to simulate gh checkout: ${error.message}` @@ -496,6 +500,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.fail( new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: input.cwd, detail: `Unexpected repository lookup: ${repository}`, }), ); @@ -516,6 +522,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.fail( new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: input.cwd, detail: `Unexpected gh command: ${args.join(" ")}`, }), ); @@ -595,6 +603,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.fail( new GitHubCli.GitHubCliError({ operation: "createRepository", + command: "gh", + cwd: input.cwd, detail: `Unexpected repository create: ${input.repository}`, }), ), @@ -1333,6 +1343,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { failWith: new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: repoDir, detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, @@ -2471,6 +2483,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { failWith: new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: repoDir, detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), }, @@ -2500,6 +2514,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { failWith: new GitHubCli.GitHubCliError({ operation: "execute", + command: "gh", + cwd: repoDir, detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", }), }, diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 52aedd1d760a..8617f14e3655 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -329,4 +330,26 @@ describe("AzureDevOpsCli.layer", () => { }); }).pipe(Effect.provide(layer)), ); + + it.effect("preserves VCS causes without copying upstream details into messages", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "AzureDevOpsCli.execute", + command: "az repos list --organization sensitive-upstream-detail", + cwd: "/repo", + exitCode: 1, + detail: "sensitive-upstream-detail", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("sensitive-upstream-detail"), false); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 442cae689346..ea0e52868724 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, @@ -14,7 +13,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeAzureDevOpsPullRequestJson, decodeAzureDevOpsPullRequestListJson, - formatAzureDevOpsJsonDecodeError, type NormalizedAzureDevOpsPullRequestRecord, } from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -25,6 +23,8 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass( schema: S, operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", invalidDetail: string, + cwd: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( (error) => new AzureDevOpsCliError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + command: "az", + cwd, + detail: invalidDetail, cause: error, }), ), @@ -249,7 +255,14 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); + .pipe( + Effect.mapError((error) => + AzureDevOpsCliError.fromVcsError( + { operation: "execute", command: "az", cwd: input.cwd }, + error, + ), + ), + ); const executeJson = (input: Parameters[0]) => execute({ @@ -286,7 +299,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new AzureDevOpsCliError({ operation: "listPullRequests", - detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + detail: "Azure DevOps CLI returned invalid PR list JSON.", cause: decoded.failure, }), ); @@ -318,7 +333,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new AzureDevOpsCliError({ operation: "getPullRequest", - detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + command: "az", + cwd: input.cwd, + detail: "Azure DevOps CLI returned invalid pull request JSON.", cause: decoded.failure, }), ); @@ -341,6 +358,7 @@ export const make = Effect.gen(function* () { RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -370,6 +388,7 @@ export const make = Effect.gen(function* () { RawAzureDevOpsRepositorySchema, "createRepository", "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -407,6 +426,7 @@ export const make = Effect.gen(function* () { RawAzureDevOpsRepositorySchema, "getDefaultBranch", "Azure DevOps CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index f007ecf79852..1341f4cc08d6 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -46,6 +46,46 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); +it.effect("adds change-request context while retaining Azure CLI causes", () => + Effect.gen(function* () { + const cause = new AzureDevOpsCli.AzureDevOpsCliError({ + operation: "execute", + command: "az", + cwd: "/repo", + detail: "Azure DevOps CLI command failed.", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + checkoutPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: "az", + cwd: "/repo", + reference: "#42", + detail: "Azure DevOps CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { let createInput: diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8cd5bd7522d9..bf2ac9829275 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -12,18 +12,6 @@ import { type SourceControlCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; -function providerError( - operation: string, - cause: AzureDevOpsCli.AzureDevOpsCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "azure-devops", - operation, - detail: cause.detail, - cause, - }); -} - function parseAzureAuth(input: SourceControlAuthProbeInput) { const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); @@ -101,13 +89,39 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); }, getChangeRequest: (input) => azure.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -121,20 +135,71 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - azure - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + azure.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - azure - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + azure.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - azure - .getDefaultBranch({ cwd: input.cwd }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + azure.getDefaultBranch({ cwd: input.cwd }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => azure .checkoutPullRequest({ @@ -142,7 +207,22 @@ export const make = Effect.gen(function* () { reference: input.reference, ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 8530e163dc62..75ac877cd432 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -51,6 +51,45 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( }), ); +it.effect("adds repository context while retaining Bitbucket API causes", () => + Effect.gen(function* () { + const cause = new BitbucketApi.BitbucketApiError({ + operation: "getRepository", + detail: "upstream detail that should remain in the cause", + status: 503, + cause: new Error("raw upstream failure"), + }); + const provider = yield* makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(cause), + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + command: undefined, + cwd: "/repo", + repository: "owner/repo", + detail: "Failed to get repository clone URLs.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); + }), +); + it.effect("lists Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { let listInput: Parameters[0] | null = diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 6c1d67434bfc..974fbb94a393 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -8,18 +8,6 @@ import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullReques import * as SourceControlProvider from "./SourceControlProvider.ts"; import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; -function providerError( - operation: string, - cause: BitbucketApi.BitbucketApiError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "bitbucket", - operation, - detail: cause.detail, - cause, - }); -} - function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { provider: "bitbucket", @@ -60,13 +48,37 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "listChangeRequests", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to list change requests.", + cause: error, + }), + ), ); }, getChangeRequest: (input) => bitbucket.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to get change request.", + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -81,23 +93,72 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to create change request.", + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - bitbucket - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + bitbucket.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to get repository clone URLs.", + cause: error, + }), + ), + ), createRepository: (input) => - bitbucket - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + bitbucket.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createRepository", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to create repository.", + cause: error, + }), + ), + ), getDefaultBranch: (input) => bitbucket .getDefaultBranch({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), }) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getDefaultBranch", + cwd: input.cwd, + detail: "Failed to get default branch.", + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => bitbucket .checkoutPullRequest({ @@ -106,7 +167,21 @@ export const make = Effect.gen(function* () { reference: input.reference, ...(input.force !== undefined ? { force: input.force } : {}), }) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to check out change request.", + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index e0e781bd8b56..7c8c9b037be6 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -269,18 +269,15 @@ describe("GitHubCli.layer", () => { it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh pr view", - cwd: "/repo", - exitCode: 1, - detail: - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); const gh = yield* GitHubCli.GitHubCli; const error = yield* gh @@ -291,6 +288,10 @@ describe("GitHubCli.layer", () => { .pipe(Effect.flip); assert.equal(error.message.includes("Pull request not found"), true); + assert.strictEqual(error.command, "gh"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 836c7e1eb74e..4cdf38ec2b81 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import { TrimmedNonEmptyString, @@ -15,19 +14,71 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { operation: Schema.String, + command: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { override get message(): string { return `GitHub CLI failed in ${this.operation}: ${this.detail}`; } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "gh"; + readonly cwd: string; + }, + error: VcsError | unknown, + ): GitHubCliError { + const lower = errorText(error).toLowerCase(); + + if (lower.includes("command not found: gh") || lower.includes("enoent")) { + return new GitHubCliError({ + ...context, + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHubCliError({ + ...context, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + ...context, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new GitHubCliError({ + ...context, + detail: "GitHub CLI command failed.", + cause: error, + }); + } } export interface GitHubPullRequestSummary { @@ -110,54 +161,6 @@ function errorText(error: VcsError | unknown): string { return String(error); } -function normalizeGitHubCliError( - operation: "execute" | "stdout", - error: VcsError | unknown, -): GitHubCliError { - const text = errorText(error); - const lower = text.toLowerCase(); - - if (lower.includes("command not found: gh") || lower.includes("enoent")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); - } - - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") - ) { - return new GitHubCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: text, - cause: error, - }); -} - const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, @@ -216,13 +219,16 @@ function decodeGitHubJson( schema: S, operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", invalidDetail: string, + cwd: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( (error) => new GitHubCliError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + command: "gh", + cwd, + detail: invalidDetail, cause: error, }), ), @@ -241,7 +247,14 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); + .pipe( + Effect.mapError((error) => + GitHubCliError.fromVcsError( + { operation: "execute", command: "gh", cwd: input.cwd }, + error, + ), + ), + ); return GitHubCli.of({ execute, @@ -271,7 +284,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitHubCliError({ operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + command: "gh", + cwd: input.cwd, + detail: "GitHub CLI returned invalid PR list JSON.", cause: decoded.failure, }), ); @@ -303,7 +318,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitHubCliError({ operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + command: "gh", + cwd: input.cwd, + detail: "GitHub CLI returned invalid pull request JSON.", cause: decoded.failure, }), ); @@ -328,6 +345,7 @@ export const make = Effect.gen(function* () { RawGitHubRepositoryCloneUrlsSchema, "getRepositoryCloneUrls", "GitHub CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 141672c91c56..c1aa8680b26b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -68,6 +68,49 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); +it.effect("adds safe request context while retaining GitHub CLI causes", () => + Effect.gen(function* () { + const cause = new GitHubCli.GitHubCliError({ + operation: "execute", + command: "gh", + cwd: "/repo", + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + getPullRequest: () => Effect.fail(cause), + }); + + const error = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + reference: error.reference, + detail: error.detail, + }, + { + provider: "github", + operation: "getChangeRequest", + command: "gh", + cwd: "/repo", + reference: "https://github.com/pingdotgg/t3code/pull/42", + detail: "Pull request not found. Check the PR number or URL and try again.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("uses gh json listing for non-open change request state queries", () => Effect.gen(function* () { let executeArgs: ReadonlyArray = []; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b84d2504f93e..60298888e6c0 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; import { SourceControlProviderError, type ChangeRequest, @@ -20,19 +19,6 @@ import { type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; -const isSourceControlProviderError = Schema.is(SourceControlProviderError); - -function providerError( - operation: string, - cause: GitHubCli.GitHubCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "github", - operation, - detail: cause.detail, - cause, - }); -} function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { @@ -122,7 +108,20 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); } @@ -162,6 +161,11 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", + command: "gh", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), detail: "GitHub CLI returned invalid change request JSON.", cause: decoded.failure, }), @@ -169,11 +173,20 @@ export const make = Effect.gen(function* () { ), ); }), - Effect.mapError((error) => - isSourceControlProviderError(error) - ? error - : providerError("listChangeRequests", error), - ), + Effect.catchTags({ + GitHubCliError: (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + }), ); }; @@ -183,7 +196,20 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => github @@ -194,23 +220,87 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ), getRepositoryCloneUrls: (input) => - github - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + github.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - github - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + github.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - github - .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + github.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => - github - .checkoutPullRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + github.checkoutPullRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index f7c3b3e4bf02..792e3a82b139 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -313,17 +313,14 @@ layer("GitLabCli.layer", (it) => { it.effect("surfaces a friendly error when the merge request is not found", () => Effect.gen(function* () { - mockedRun.mockReturnValueOnce( - Effect.fail( - new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab mr view 4888", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 merge request not found", - }), - ), - ); + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab mr view 4888", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); const error = yield* Effect.gen(function* () { const glab = yield* GitLabCli.GitLabCli; @@ -334,6 +331,10 @@ layer("GitLabCli.layer", (it) => { }).pipe(Effect.flip); assert.equal(error.message.includes("Merge request not found"), true); + assert.strictEqual(error.command, "glab"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); }), ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index c5fd7ee52f02..b34e72ffc953 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -4,16 +4,18 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import type * as DateTime from "effect/DateTime"; -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitLabMergeRequestJson, decodeGitLabMergeRequestListJson, - formatGitLabJsonDecodeError, } from "./gitLabMergeRequests.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000; export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { operation: Schema.String, + command: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { override get message(): string { return `GitLab CLI failed in ${this.operation}: ${this.detail}`; } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + }, + error: VcsError | unknown, + ): GitLabCliError { + const lower = errorText(error).toLowerCase(); + + if (lower.includes("command not found: glab") || isVcsProcessSpawnError(error)) { + return new GitLabCliError({ + ...context, + detail: "GitLab CLI (`glab`) is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("glab auth login") || + lower.includes("token") + ) { + return new GitLabCliError({ + ...context, + detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("merge request not found") || + lower.includes("not found") || + lower.includes("404") + ) { + return new GitLabCliError({ + ...context, + detail: "Merge request not found. Check the MR number or URL and try again.", + cause: error, + }); + } + + return new GitLabCliError({ + ...context, + detail: "GitLab CLI command failed.", + cause: error, + }); + } } export interface GitLabMergeRequestSummary { @@ -48,6 +102,17 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + export class GitLabCli extends Context.Service< GitLabCli, { @@ -112,56 +177,6 @@ function isVcsProcessSpawnError(error: unknown): boolean { ); } -function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI (`glab`) is required but not available on PATH.", - cause: error, - }); - } - - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("glab auth login") || - lower.includes("token") - ) { - return new GitLabCliError({ - operation, - detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("merge request not found") || - lower.includes("not found") || - lower.includes("404") - ) { - return new GitLabCliError({ - operation, - detail: "Merge request not found. Check the MR number or URL and try again.", - cause: error, - }); - } - - return new GitLabCliError({ - operation, - detail: `GitLab CLI command failed: ${error.message}`, - cause: error, - }); - } - - return new GitLabCliError({ - operation, - detail: "GitLab CLI command failed.", - cause: error, - }); -} - const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, web_url: TrimmedNonEmptyString, @@ -192,13 +207,16 @@ function decodeGitLabJson( schema: S, operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", invalidDetail: string, + cwd: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( (error) => new GitLabCliError({ operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + command: "glab", + cwd, + detail: invalidDetail, cause: error, }), ), @@ -274,7 +292,14 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); + .pipe( + Effect.mapError((error) => + GitLabCliError.fromVcsError( + { operation: "execute", command: "glab", cwd: input.cwd }, + error, + ), + ), + ); return GitLabCli.of({ execute, @@ -303,7 +328,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitLabCliError({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, + detail: "GitLab CLI returned invalid MR list JSON.", cause: decoded.failure, }), ); @@ -327,7 +354,9 @@ export const make = Effect.gen(function* () { return Effect.fail( new GitLabCliError({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, + command: "glab", + cwd: input.cwd, + detail: "GitLab CLI returned invalid merge request JSON.", cause: decoded.failure, }), ); @@ -350,6 +379,7 @@ export const make = Effect.gen(function* () { RawGitLabRepositoryCloneUrlsSchema, "getRepositoryCloneUrls", "GitLab CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -368,6 +398,7 @@ export const make = Effect.gen(function* () { RawGitLabNamespaceSchema, "createRepository", "GitLab CLI returned invalid namespace JSON.", + input.cwd, ), ), Effect.map((namespace) => namespace.id), @@ -402,6 +433,7 @@ export const make = Effect.gen(function* () { RawGitLabRepositoryCloneUrlsSchema, "createRepository", "GitLab CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -440,6 +472,7 @@ export const make = Effect.gen(function* () { RawGitLabDefaultBranchSchema, "getDefaultBranch", "GitLab CLI returned invalid repository JSON.", + input.cwd, ), ), Effect.map((value) => value.default_branch ?? null), diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 3dc61e132f3b..6ab3f23b150d 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -52,6 +52,50 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = }), ); +it.effect("adds repository context while retaining GitLab CLI causes", () => + Effect.gen(function* () { + const cause = new GitLabCli.GitLabCliError({ + operation: "execute", + command: "glab", + cwd: "/repo", + detail: "GitLab CLI command failed.", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + createRepository: () => Effect.fail(cause), + }); + + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "gitlab", + operation: "createRepository", + command: "glab", + cwd: "/repo", + repository: "owner/repo", + detail: "GitLab CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it.effect("lists GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { let listInput: Parameters[0] | null = null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index d1aaf06309d3..2cba12f1b3f7 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -17,18 +17,6 @@ import { } from "./SourceControlProviderDiscovery.ts"; import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; -function providerError( - operation: string, - cause: GitLabCli.GitLabCliError, -): SourceControlProviderError { - return new SourceControlProviderError({ - provider: "gitlab", - operation, - detail: cause.detail, - cause, - }); -} - function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { return { provider: "gitlab", @@ -129,13 +117,39 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError((error) => providerError("listChangeRequests", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), ); }, getChangeRequest: (input) => gitlab.getMergeRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError((error) => providerError("getChangeRequest", error)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); @@ -149,24 +163,88 @@ export const make = Effect.gen(function* () { title: input.title, bodyFile: input.bodyFile, }) - .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); }, getRepositoryCloneUrls: (input) => - gitlab - .getRepositoryCloneUrls(input) - .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + gitlab.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => - gitlab - .createRepository(input) - .pipe(Effect.mapError((error) => providerError("createRepository", error))), + gitlab.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), getDefaultBranch: (input) => - gitlab - .getDefaultBranch(input) - .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + gitlab.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), checkoutChangeRequest: (input) => - gitlab - .checkoutMergeRequest(input) - .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + gitlab.checkoutMergeRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), }); }); diff --git a/apps/server/src/sourceControl/SourceControlProvider.test.ts b/apps/server/src/sourceControl/SourceControlProvider.test.ts new file mode 100644 index 000000000000..7e5324882799 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProvider.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; + +import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; + +it("removes URL credentials, query parameters, and fragments from error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue( + "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion", + ), + "https://example.test/org/repo/pull/42", + ); +}); + +it("normalizes control characters and bounds error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `), + `owner/repo ${"x".repeat(245)}`, + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index c2959ef878e1..5f93dbcaa425 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -22,6 +22,36 @@ export interface SourceControlRefSelector { readonly repository?: string; } +const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; + +/** + * Sanitizes user-provided source-control identifiers before attaching them to + * contract errors. This is intentionally narrower than request validation: it + * only strips URL secrets and bounds diagnostic values sent over transport. + */ +export function transportSafeSourceControlErrorValue(value: string): string { + let printable = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; + } + const normalized = printable.trim().replace(/\s+/gu, " "); + + let safe = normalized; + try { + const url = new URL(normalized); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + safe = url.toString(); + } catch { + // Plain repository and change-request identifiers are not URLs. + } + + return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); +} + export function parseSourceControlOwnerRef( headSelector: string, ): SourceControlRefSelector | undefined { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 6cea2d9a4963..5c4d27e46f94 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -38,6 +39,7 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; + readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; }) { const driver = { listRemotes: () => @@ -57,21 +59,23 @@ function makeRegistry(input: { const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), - resolve: () => - Effect.succeed({ - kind: "git", - repository: { + resolve: + input.resolve ?? + (() => + Effect.succeed({ kind: "git", - rootPath: "/repo", - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: TEST_EPOCH, - expiresAt: Option.none(), + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, }, - }, - driver: driver as unknown as VcsDriver.VcsDriver["Service"], - }), + driver: driver as unknown as VcsDriver.VcsDriver["Service"], + })), }); const processLayer = Layer.mock(VcsProcess.VcsProcess)({ @@ -120,6 +124,46 @@ it.effect("routes directly by provider kind for remote-first workflows", () => }), ); +it.effect("includes the request cwd when an unregistered provider is used", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ remotes: [] }); + const provider = yield* registry.get("unknown"); + + const error = yield* provider + .getChangeRequest({ cwd: "/repo", reference: "#42" }) + .pipe(Effect.flip); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "getChangeRequest"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "#42"); + }), +); + +it.effect("retains VCS detection failures with structured cwd context", () => + Effect.gen(function* () { + const cause = new VcsRepositoryDetectionError({ + operation: "resolve", + cwd: "/repo", + detail: "raw VCS detection failure", + cause: new Error("raw nested failure"), + }); + const registry = yield* makeRegistry({ + remotes: [], + resolve: () => Effect.fail(cause), + }); + + const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "detectProvider"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.detail, "Failed to detect source control provider."); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.message), false); + }), +); + it.effect("routes GitLab remotes to the GitLab provider", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index b1f1ea7aae70..fb70d677e435 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -64,33 +64,62 @@ export class SourceControlProviderRegistry extends Context.Service< function unsupportedProvider( kind: SourceControlProviderKind, ): SourceControlProvider.SourceControlProvider["Service"] { - const unsupported = (operation: string) => - Effect.fail( + return SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests: (input) => new SourceControlProviderError({ provider: kind, - operation, + operation: "listChangeRequests", + cwd: input.cwd, + detail: `No ${kind} source control provider is registered.`, + }), + getChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), + detail: `No ${kind} source control provider is registered.`, + }), + createChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "createChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.headSelector), + detail: `No ${kind} source control provider is registered.`, + }), + getRepositoryCloneUrls: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), + detail: `No ${kind} source control provider is registered.`, + }), + createRepository: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "createRepository", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), + detail: `No ${kind} source control provider is registered.`, + }), + getDefaultBranch: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getDefaultBranch", + cwd: input.cwd, + detail: `No ${kind} source control provider is registered.`, + }), + checkoutChangeRequest: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), detail: `No ${kind} source control provider is registered.`, }), - ); - - return SourceControlProvider.SourceControlProvider.of({ - kind, - listChangeRequests: () => unsupported("listChangeRequests"), - getChangeRequest: () => unsupported("getChangeRequest"), - createChangeRequest: () => unsupported("createChangeRequest"), - getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), - createRepository: () => unsupported("createRepository"), - getDefaultBranch: () => unsupported("getDefaultBranch"), - checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), - }); -} - -function providerDetectionError(operation: string, cwd: string, cause: unknown) { - return new SourceControlProviderError({ - provider: "unknown", - operation, - detail: `Failed to detect source control provider for ${cwd}.`, - cause, }); } @@ -180,12 +209,30 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( function* (cwd: string) { - const handle = yield* vcsRegistry - .resolve({ cwd }) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); - const remotes = yield* handle.driver - .listRemotes(cwd) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + const handle = yield* vcsRegistry.resolve({ cwd }).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + ), + ); + const remotes = yield* handle.driver.listRemotes(cwd).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + ), + ); const context = selectProviderContext(remotes.remotes); return yield* refineUnknownRemoteProvider({ diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 0ecf13c67e69..104aadd9161f 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -155,6 +155,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 12:33:22 -0700 Subject: [PATCH 10/80] [codex] Sanitize text generation CLI errors (#3431) Co-authored-by: codex --- .../src/textGeneration/TextGenerationPrompts.test.ts | 12 ++++++++++++ .../server/src/textGeneration/TextGenerationUtils.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 1435bc522b8c..b67e8b93c4aa 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -190,4 +190,16 @@ describe("normalizeCliError", () => { expect(result).toBeInstanceOf(TextGenerationError); expect(result.detail).toBe("fallback"); }); + + it("does not expose CLI failure details in the public error message", () => { + const result = normalizeCliError( + "codex", + "generateCommitMessage", + new Error("request failed with access_token=secret-token"), + "Failed to generate a commit message", + ); + + expect(result.detail).toBe("Failed to generate a commit message"); + expect(result.message).not.toContain("secret-token"); + }); }); diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index a786f81b2c88..ad2911c20f76 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -99,7 +99,7 @@ export function normalizeCliError( } return new TextGenerationError({ operation, - detail: `${fallback}: ${error.message}`, + detail: fallback, cause: error, }); } From eb5eb0d9fa99364aa3b5091c62d6a187f5eef375 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:58:10 -0700 Subject: [PATCH 11/80] [codex] Structure managed endpoint allocation failures (#3421) Co-authored-by: codex --- .../ManagedEndpointAllocations.ts | 2 +- .../ManagedEndpointProvider.test.ts | 60 ++++- .../environments/ManagedEndpointProvider.ts | 245 +++++++++--------- 3 files changed, 177 insertions(+), 130 deletions(-) diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index c951ee03c8d1..f6cefa690714 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -120,7 +120,7 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) => eq(relayManagedEndpointAllocations.environmentId, input.environmentId), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return ManagedEndpointAllocations.of({ diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 479be4123805..56bf6319d0d9 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -133,7 +133,7 @@ function makeDnsClient( operation: "update-record", hostname: request.name, dnsRecordId, - cause: `DNS record ${dnsRecordId} does not exist.`, + cause: { _tag: "NotFound", dnsRecordId }, }); } }), @@ -531,6 +531,59 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); + it.effect("does not hide non-not-found checkpoint update failures", () => { + const dnsCalls: DnsCall[] = []; + const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + operation: "update-record", + dnsRecordId: "created-record-id", + cause: new Error("Cloudflare DNS unavailable"), + }); + let records: ReadonlyArray<{ readonly id: string }> = []; + const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listRecords: (hostname) => + Effect.sync(() => { + dnsCalls.push({ operation: "listRecords", input: hostname }); + return records; + }), + createRecord: (request) => + Effect.sync(() => { + dnsCalls.push({ operation: "createRecord", input: request }); + const record = { id: "created-record-id" }; + records = [record]; + return record; + }), + updateRecord: (dnsRecordId, request) => + Effect.sync(() => { + dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); + }).pipe(Effect.andThen(Effect.fail(failure))), + deleteRecord: () => Effect.void, + }); + const layer = providerLayer(makePersistentTunnelClient(), dnsClient, makeAllocations()); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const request = { + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + } as const; + yield* provider.provision(request); + const error = yield* Effect.flip(provider.provision(request)); + + expect(error).toMatchObject({ + _tag: "ManagedEndpointProvisioningFailed", + stage: "ensure-dns-record", + userId: "user_ABC", + environmentId: "env_ABC", + }); + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "updateRecord", + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect( "deprovisions checkpointed DNS and tunnel resources before removing the allocation", () => { @@ -765,11 +818,11 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); }); - it.effect("reports malformed tunnel responses without manufacturing a cause", () => { + it.effect("reports mismatched tunnel responses without manufacturing a cause", () => { const dnsCalls: DnsCall[] = []; const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ ...makeTunnelClient(), - create: () => Effect.succeed({ id: "returned-tunnel-id", name: null }), + create: () => Effect.succeed({ id: "returned-tunnel-id", name: "unexpected-tunnel" }), }); return Effect.gen(function* () { @@ -790,6 +843,7 @@ describe("ManagedEndpointProvider", () => { hostname: expectedManagedHostname("env_ABC"), tunnelName: expectedManagedTunnelName("env_ABC"), returnedTunnelId: "returned-tunnel-id", + returnedTunnelName: "unexpected-tunnel", }); if (error._tag === "ManagedEndpointProvisioningFailed") { expect(error.cause).toBeUndefined(); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index bb2dd4b0ce9a..68e93f8b17ca 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -192,11 +192,8 @@ export class ManagedEndpointTunnelClient extends Context.Service< } >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {} -export const makeTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) => - ManagedEndpointTunnelClient.of(client); - export const layerTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) => - Layer.succeed(ManagedEndpointTunnelClient, makeTunnelClient(client)); + Layer.succeed(ManagedEndpointTunnelClient, client); interface ManagedEndpointCnameRecordInput { readonly type: "CNAME"; @@ -247,11 +244,8 @@ export class ManagedEndpointDnsClient extends Context.Service< } >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {} -export const makeDnsClient = (client: ManagedEndpointDnsClient["Service"]) => - ManagedEndpointDnsClient.of(client); - export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) => - Layer.succeed(ManagedEndpointDnsClient, makeDnsClient(client)); + Layer.succeed(ManagedEndpointDnsClient, client); const requireCloudflareSettings = Effect.fnUntraced(function* ( settings: RelayConfiguration.RelayConfiguration["Service"], @@ -331,7 +325,7 @@ const ignoreNotFound = ( }), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const crypto = yield* Crypto.Crypto; const tunnels = yield* ManagedEndpointTunnelClient; @@ -366,7 +360,10 @@ const make = Effect.gen(function* () { .updateRecord(preferredDnsRecordId, dnsRecord) .pipe( Effect.as(true), - Effect.orElseSucceed(() => false), + Effect.catchTags({ + ManagedEndpointDnsClientError: (error) => + isNotFoundCause(error.cause) ? Effect.succeed(false) : Effect.fail(error), + }), ); if (checkpointedRecordUpdated) { return preferredDnsRecordId; @@ -550,7 +547,7 @@ const make = Effect.gen(function* () { }), ), ); - if (!tunnelResponse.id || !tunnelResponse.name) { + if (!tunnelResponse.id || tunnelResponse.name !== tunnelName) { return yield* new ManagedEndpointProvisioningFailed({ userId: input.userId, environmentId: input.environmentId, @@ -712,131 +709,127 @@ export const layerCloudflareBindings = ( layer.pipe( Layer.provide( Layer.mergeAll( - layerTunnelClient( - ManagedEndpointTunnelClient.of({ - list: (request) => - tunnelClient.list(request).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "list", - tunnelName: request.name, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + layerTunnelClient({ + list: (request) => + tunnelClient.list(request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "list", + tunnelName: request.name, + cause, + }), ), - create: (request) => - tunnelClient.create(request).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "create", - tunnelName: request.name, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + create: (request) => + tunnelClient.create(request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "create", + tunnelName: request.name, + cause, + }), ), - putConfiguration: (tunnelId, config) => - tunnelClient.putConfiguration(tunnelId, config).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "put-configuration", - tunnelId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + putConfiguration: (tunnelId, config) => + tunnelClient.putConfiguration(tunnelId, config).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "put-configuration", + tunnelId, + cause, + }), ), - getToken: (tunnelId) => - tunnelClient.getToken(tunnelId).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "get-token", - tunnelId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + getToken: (tunnelId) => + tunnelClient.getToken(tunnelId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "get-token", + tunnelId, + cause, + }), ), - delete: (tunnelId) => - tunnelClient.delete(tunnelId).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointTunnelClientError({ - operation: "delete", - tunnelId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + delete: (tunnelId) => + tunnelClient.delete(tunnelId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId, + cause, + }), ), - }), - ), - layerDnsClient( - ManagedEndpointDnsClient.of({ - listRecords: (hostname) => - dnsClient.listDnsRecords({ search: hostname }).pipe( - Effect.map((response) => - response.result.filter( - (record): record is typeof record & { readonly id: string } => - typeof record.id === "string" && - normalizeHostname(record.name) === normalizeHostname(hostname), - ), - ), - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "list-records", - hostname, - cause, - }), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), + layerDnsClient({ + listRecords: (hostname) => + dnsClient.listDnsRecords({ search: hostname }).pipe( + Effect.map((response) => + response.result.filter( + (record): record is typeof record & { readonly id: string } => + typeof record.id === "string" && + normalizeHostname(record.name) === normalizeHostname(hostname), ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), - createRecord: (request) => - dnsClient.createDnsRecord(request).pipe( - Effect.map((response) => ({ id: response.id })), - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "create-record", - hostname: request.name, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "list-records", + hostname, + cause, + }), ), - updateRecord: (dnsRecordId, request) => - dnsClient.updateDnsRecord(dnsRecordId, request).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "update-record", - hostname: request.name, - dnsRecordId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + createRecord: (request) => + dnsClient.createDnsRecord(request).pipe( + Effect.map((response) => ({ id: response.id })), + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "create-record", + hostname: request.name, + cause, + }), ), - deleteRecord: (dnsRecordId) => - dnsClient.deleteDnsRecord(dnsRecordId).pipe( - Effect.mapError( - (cause) => - new ManagedEndpointDnsClientError({ - operation: "delete-record", - dnsRecordId, - cause, - }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + updateRecord: (dnsRecordId, request) => + dnsClient.updateDnsRecord(dnsRecordId, request).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "update-record", + hostname: request.name, + dnsRecordId, + cause, + }), ), - }), - ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + deleteRecord: (dnsRecordId) => + dnsClient.deleteDnsRecord(dnsRecordId).pipe( + Effect.mapError( + (cause) => + new ManagedEndpointDnsClientError({ + operation: "delete-record", + dnsRecordId, + cause, + }), + ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), ), ), ); From be56fa43b0aaad6ab537526e2129501038bbb6a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:58:59 -0700 Subject: [PATCH 12/80] [codex] Preserve workspace root stat failures (#3278) Co-authored-by: codex --- apps/server/src/server.test.ts | 34 +++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 1 + .../src/workspace/WorkspacePaths.test.ts | 74 +++++++++++++++++++ apps/server/src/workspace/WorkspacePaths.ts | 59 +++++++++++++-- apps/server/src/ws.ts | 6 ++ packages/contracts/src/project.ts | 1 + 6 files changed, 168 insertions(+), 7 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 32a7cc17944a..e1daf20ed570 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, @@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports workspace root stat failures without relabeling them as missing", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blockedRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-stat-error-", + }); + const workspaceRoot = path.join(blockedRoot, "workspace"); + yield* fs.makeDirectory(workspaceRoot); + yield* fs.chmod(blockedRoot, 0o000); + + const result = yield* Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + return yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsListEntries]({ cwd: workspaceRoot }).pipe(Effect.result), + ), + ); + }).pipe(Effect.ensuring(fs.chmod(blockedRoot, 0o700).pipe(Effect.ignore))); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectListEntriesError") { + assert.fail("Expected a ProjectListEntriesError"); + } + const error = result.failure; + assert.equal(error.failure, "workspace_root_stat_failed"); + assert.equal(error.normalizedCwd, workspaceRoot); + assert.equal(error.detail, "validate-existing"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index cdb26a38bc77..81fb735ea2eb 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ export const WorkspaceEntriesError = Schema.Union([ WorkspacePaths.WorkspaceRootNotExistsError, WorkspacePaths.WorkspaceRootCreateFailedError, + WorkspacePaths.WorkspaceRootStatFailedError, WorkspacePaths.WorkspaceRootNotDirectoryError, WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index ecce54b67d6b..4f3bc833b4c5 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { expect(error.message).toContain("Workspace root is not a directory:"); }), ); + + it.effect("preserves non-NotFound stat failures while validating the root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: "Test PermissionDenied stat failure.", + }), + ), + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./permission-denied "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "validate-existing", + }); + }), + ); + + it.effect("preserves stat failures while verifying a newly created root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let statCalls = 0; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => { + statCalls += 1; + const reason = statCalls === 1 ? "NotFound" : "PermissionDenied"; + return Effect.fail( + PlatformError.systemError({ + _tag: reason, + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: `Test ${reason} stat failure.`, + }), + ); + }, + makeDirectory: () => Effect.void, + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./created-then-unreadable "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "verify-created", + }); + }), + ); }); describe("resolveRelativePathWithinRoot", () => { diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 85e3db561c48..5acf6677cdef 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()( + "WorkspaceRootStatFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + phase: Schema.Literals(["validate-existing", "verify-created"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`; + } +} + export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { @@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass Effect.Effect< string, - WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError + | WorkspaceRootNotExistsError + | WorkspaceRootCreateFailedError + | WorkspaceRootStatFailedError + | WorkspaceRootNotDirectoryError >; /** * Resolve a relative path within a validated workspace root. @@ -117,13 +135,38 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* ( + workspaceRoot: string, + normalizedWorkspaceRoot: string, + phase: WorkspaceRootStatFailedError["phase"], + ) { + return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new WorkspaceRootStatFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + phase, + cause, + }), + ), + onSuccess: Effect.succeed, + }), + ); + }); + const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - let workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); + let workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "validate-existing", + ); if (!workspaceStat && options?.createIfMissing) { yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( Effect.mapError( @@ -135,9 +178,11 @@ export const make = Effect.gen(function* () { }), ), ); - workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); + workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "verify-created", + ); } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7c45d0b58b8e..05e78de476cf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr failure: "workspace_root_create_failed", normalizedCwd: error.normalizedWorkspaceRoot, }; + case "WorkspaceRootStatFailedError": + return { + failure: "workspace_root_stat_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + detail: error.phase, + }; case "WorkspaceRootNotDirectoryError": return { failure: "workspace_root_not_directory", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 338b87096d9c..d59b9770ad32 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; export const ProjectEntriesFailure = Schema.Literals([ "workspace_root_not_found", "workspace_root_create_failed", + "workspace_root_stat_failed", "workspace_root_not_directory", "search_index_create_failed", "search_index_scan_timed_out", From 6e9b43cb84c1b93c348d76880a0bf0083f395e47 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:11:52 -0700 Subject: [PATCH 13/80] Preserve trace file read causes (#3300) Co-authored-by: codex --- .../src/diagnostics/TraceDiagnostics.test.ts | 47 ++++++++++--- .../src/diagnostics/TraceDiagnostics.ts | 68 +++++++++++++------ 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index d4ffa4a5fc29..70bb4dc815c3 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -3,8 +3,10 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; import * as TraceDiagnostics from "./TraceDiagnostics.ts"; @@ -187,18 +189,17 @@ describe("TraceDiagnostics", () => { it.effect("keeps loaded trace data when one rotated trace file fails to read", () => Effect.gen(function* () { const traceFilePath = "/tmp/server.trace.ndjson"; + const readFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: `${traceFilePath}.1`, + }); const fileSystemLayer = FileSystem.layerNoop({ readFileString: (path) => path === `${traceFilePath}.1` - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - description: "permission denied", - pathOrDescriptor: path, - }), - ) + ? Effect.fail(readFailure) : Effect.succeed( record({ name: "server.getConfig", @@ -209,20 +210,44 @@ describe("TraceDiagnostics", () => { }), ), }); + const logAnnotations: Array> = []; + const logger = Logger.make((options) => { + logAnnotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) }); + }); const diagnostics = yield* TraceDiagnostics.readTraceDiagnostics({ traceFilePath, maxFiles: 1, readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - }).pipe(Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)))); + }).pipe( + Effect.provide( + Layer.mergeAll( + TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); assert.equal(diagnostics.recordCount, 1); assert.equal( Option.getOrElse(diagnostics.partialFailure, () => false), true, ); - assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-read-failed"); + assert.deepStrictEqual(Option.getOrUndefined(diagnostics.error), { + kind: "trace-file-read-failed", + message: `Failed to read local trace file '${traceFilePath}.1'.`, + }); assert.deepStrictEqual(diagnostics.scannedFilePaths, [`${traceFilePath}.1`, traceFilePath]); + + const failureLog = logAnnotations.find( + (annotations) => annotations.traceFilePath === `${traceFilePath}.1`, + ); + assert.exists(failureLog); + assert.deepStrictEqual(failureLog, { + traceFilePath: `${traceFilePath}.1`, + errorTag: "TraceFileReadError", + causeTag: "PermissionDenied", + }); }), ); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index d396f4e4ee97..d54e033380c0 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -14,6 +14,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; interface TraceRecordLike { readonly name?: unknown; @@ -39,6 +41,19 @@ export interface TraceDiagnosticsOptions { readonly readAt?: DateTime.Utc; } +export class TraceFileReadError extends Schema.TaggedErrorClass()( + "TraceFileReadError", + { + traceFilePath: Schema.String, + causeTag: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read local trace file '${this.traceFilePath}'.`; + } +} + export class TraceDiagnostics extends Context.Service< TraceDiagnostics, { @@ -153,10 +168,6 @@ function isNotFoundError(error: PlatformError.PlatformError): boolean { return error.reason._tag === "NotFound"; } -function platformErrorMessage(error: PlatformError.PlatformError): string { - return error.message || String(error); -} - function insertBoundedSlowestSpan( slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], span: ServerTraceDiagnosticsSpanOccurrence, @@ -377,22 +388,26 @@ export function aggregateTraceDiagnostics( type TraceFileReadResult = | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } - | { readonly _tag: "Missing"; readonly path: string } - | { readonly _tag: "Failed"; readonly path: string; readonly message: string }; + | { readonly _tag: "Missing"; readonly path: string }; function readTraceFile( fileSystem: FileSystem.FileSystem, path: string, -): Effect.Effect { +): Effect.Effect { return fileSystem.readFileString(path).pipe( - Effect.map((text) => ({ _tag: "Loaded" as const, path, text })), - Effect.catch((error: PlatformError.PlatformError) => - Effect.succeed( - isNotFoundError(error) - ? { _tag: "Missing" as const, path } - : { _tag: "Failed" as const, path, message: platformErrorMessage(error) }, - ), - ), + Effect.map((text): TraceFileReadResult => ({ _tag: "Loaded", path, text })), + Effect.catchTags({ + PlatformError: (cause) => + isNotFoundError(cause) + ? Effect.succeed({ _tag: "Missing", path }) + : Effect.fail( + new TraceFileReadError({ + traceFilePath: path, + causeTag: cause.reason._tag, + cause, + }), + ), + }), ); } @@ -405,19 +420,34 @@ export const make = Effect.gen(function* () { const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); const results = yield* Effect.all( - paths.map((path) => readTraceFile(fileSystem, path)), + paths.map((path) => + readTraceFile(fileSystem, path).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to read local trace file.").pipe( + Effect.annotateLogs({ + traceFilePath: cause.traceFilePath, + errorTag: cause._tag, + causeTag: cause.causeTag, + }), + ), + ), + Effect.result, + ), + ), { concurrency: 1, }, ); const files = results.flatMap((result) => - result._tag === "Loaded" ? [{ path: result.path, text: result.text }] : [], + Result.isSuccess(result) && result.success._tag === "Loaded" + ? [{ path: result.success.path, text: result.success.text }] + : [], ); - const readFailure = results.find((result) => result._tag === "Failed"); + const readFailure = results.find(Result.isFailure); const readFailureError = readFailure ? ({ kind: "trace-file-read-failed", - message: readFailure.message.trim() || `Failed to read ${readFailure.path}.`, + message: readFailure.failure.message, } satisfies TraceDiagnosticsErrorSummary) : undefined; From c637dfc454342c156dbe61ee8513f5e0ac0c4691 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:16:32 -0700 Subject: [PATCH 14/80] [codex] enrich Git workflow errors (#3241) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 14 +- apps/server/src/git/GitManager.ts | 143 +++++++++++------- .../server/src/git/GitWorkflowService.test.ts | 59 +++++++- apps/server/src/git/GitWorkflowService.ts | 114 +++++++------- .../src/vcs/VcsStatusBroadcaster.test.ts | 64 +++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 95 +++++++++++- packages/contracts/src/git.ts | 1 + 7 files changed, 359 insertions(+), 131 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 14490765a9d0..bb7f91ffc39f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1589,16 +1589,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* initRepo(repoDir); const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); + }).pipe(Effect.flip); - expect(errorMessage).toContain("no changes to commit"); + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "runFeatureBranchStep", + cwd: repoDir, + }); + expect(error.message).toContain("no changes to commit"); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 88eb0e212820..46da2e6c1f94 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -320,14 +320,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } -function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { - return new GitManagerError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; @@ -535,17 +527,27 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => - gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause), - ), - ); + const randomUUIDv4 = (cwd: string) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "randomUUIDv4", + cwd, + detail: "Failed to generate Git operation identifier.", + cause, + }), + ), + ); const createProgressEmitter = ( input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => - (options?.actionId === undefined ? randomUUIDv4 : Effect.succeed(options.actionId)).pipe( + (options?.actionId === undefined + ? randomUUIDv4(input.cwd) + : Effect.succeed(options.actionId) + ).pipe( Effect.map((actionId) => { const reporter = options?.progressReporter; const emit = (event: GitActionProgressPayload) => @@ -1284,16 +1286,18 @@ export const make = Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { - return yield* gitManagerError( - "runPrStep", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } if (!details.hasUpstream) { - return yield* gitManagerError( - "runPrStep", - "Current branch has not been pushed. Push before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Current branch has not been pushed. Push before creating a PR.", + }); } const headContext = yield* resolveBranchHeadContext(cwd, { @@ -1332,14 +1336,21 @@ export const make = Effect.gen(function* () { modelSelection, }); - const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`); - yield* fileSystem - .writeFileString(bodyFile, generated.body) - .pipe( - Effect.mapError((cause) => - gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), - ), - ); + const bodyFile = path.join( + tempDir, + `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`, + ); + yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Failed to write pull request body temp file.", + cause, + }), + ), + ); yield* emit({ kind: "phase_started", phase: "pr", @@ -1541,10 +1552,12 @@ export const make = Effect.gen(function* () { }; } if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } yield* materializePullRequestHeadBranch( @@ -1569,10 +1582,12 @@ export const make = Effect.gen(function* () { }; } if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } const worktree = yield* gitCore.createWorktree({ @@ -1607,10 +1622,11 @@ export const make = Effect.gen(function* () { modelSelection, }); if (!suggestion) { - return yield* gitManagerError( - "runFeatureBranchStep", - "Cannot create a feature branch because there are no changes to commit.", - ); + return yield* new GitManagerError({ + operation: "runFeatureBranchStep", + cwd, + detail: "Cannot create a feature branch because there are no changes to commit.", + }); } const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); @@ -1647,16 +1663,18 @@ export const make = Effect.gen(function* () { const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; if (input.featureBranch && !wantsCommit) { - return yield* gitManagerError( - "runStackedAction", - "Feature-branch checkout is only supported for commit actions.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Feature-branch checkout is only supported for commit actions.", + }); } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit local changes before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Commit local changes before creating a PR.", + }); } const phases: GitActionProgressPhase[] = [ @@ -1672,13 +1690,18 @@ export const make = Effect.gen(function* () { }); if (!input.featureBranch && wantsPush && !initialStatus.branch) { - return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot push from detached HEAD.", + }); } if (!input.featureBranch && wantsPr && !initialStatus.branch) { - return yield* gitManagerError( - "runStackedAction", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; @@ -1687,8 +1710,14 @@ export const make = Effect.gen(function* () { const modelSelection = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.textGenerationModelSelection), - Effect.mapError((cause) => - gitManagerError("runStackedAction", "Failed to get server settings.", cause), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Failed to get server settings.", + cause, + }), ), ); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 03cd624600d3..2ea14b951fe2 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,7 +1,9 @@ -import { assert, describe, it, vi } from "@effect/vitest"; +import { assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; + import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -132,4 +134,59 @@ describe("GitWorkflowService", () => { ), ), ); + + it.effect("structures workflow detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "GitWorkflowService.status", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git workflow.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); + + it.effect("structures command detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream command detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.listRefs", + command: "vcs-route", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git command.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f958b6630065..100b9beadbad 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -94,20 +94,6 @@ export class GitWorkflowService extends Context.Service< } >()("t3/git/GitWorkflowService") {} -const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => - new GitManagerError({ - operation, - detail: `${detail} (${cwd})`, - }); - -const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => - new GitCommandError({ - operation, - command: "vcs-route", - cwd, - detail, - }); - function nonRepositoryLocalStatus(): VcsStatusLocalResult { return { isRepo: false, @@ -153,23 +139,23 @@ export const make = Effect.gen(function* () { operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git workflow.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } }); @@ -177,48 +163,50 @@ export const make = Effect.gen(function* () { operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git command.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } }); const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } return true; }, @@ -227,26 +215,28 @@ export const make = Effect.gen(function* () { const detectGitRepositoryForCommand = Effect.fn( "GitWorkflowService.detectGitRepositoryForCommand", )(function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git command.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } return true; }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index c14115e71198..032e48e46122 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,11 +1,13 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; @@ -190,6 +192,7 @@ describe("VcsStatusBroadcaster", () => { ? Effect.fail( new GitManagerError({ operation: "VcsStatusBroadcaster.test", + cwd: "/repo", detail: "remote status failed", }), ) @@ -431,6 +434,12 @@ describe("VcsStatusBroadcaster", () => { remoteInvalidationCalls: 0, remoteStatusRefreshUpstreamValues: [] as Array, }; + const privateCwd = "/private/user/workspace/repo"; + const nestedCause = new Error("private nested VCS failure"); + const messages: Array> = []; + const logger = Logger.make(({ message }) => { + messages.push(message as ReadonlyArray); + }); let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -449,7 +458,9 @@ describe("VcsStatusBroadcaster", () => { return Effect.fail( new GitManagerError({ operation: "VcsStatusBroadcaster.test", - detail: "initial remote status failed", + cwd: privateCwd, + detail: "private initial remote status failure", + cause: nestedCause, }), ).pipe( Effect.ensuring( @@ -480,7 +491,7 @@ describe("VcsStatusBroadcaster", () => { const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach( broadcaster.streamStatus( - { cwd: "/repo" }, + { cwd: privateCwd }, { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, ), (event) => @@ -492,6 +503,24 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(firstRemoteAttemptDeferred); yield* Effect.yieldNow; assert.equal(state.remoteStatusCalls, 1); + assert.deepStrictEqual( + messages.find((message) => message[0] === "VCS remote status refresh failed"), + [ + "VCS remote status refresh failed", + { + cwdLength: privateCwd.length, + reasonCount: 1, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.test"], + defectCount: 0, + defectTags: [], + interruptionCount: 0, + consecutiveFailures: 1, + nextDelayMs: 30_000, + }, + ], + ); yield* TestClock.adjust(Duration.seconds(30)); const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); @@ -505,7 +534,15 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(testLayer, TestClock.layer()))); + }).pipe( + Effect.provide( + Layer.mergeAll( + testLayer, + TestClock.layer(), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); }); it.effect("delays automatic refresh when a cached remote snapshot is available", () => { @@ -573,6 +610,27 @@ describe("VcsStatusBroadcaster", () => { ); }); + it("summarizes refresh causes without exposing nested failure details", () => { + const nestedCause = new Error("private nested failure detail"); + const failure = new GitManagerError({ + operation: "VcsStatusBroadcaster.remoteStatus", + cwd: "/private/user/workspace/repo", + detail: "private Git failure detail", + cause: nestedCause, + }); + const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect"))); + + assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), { + reasonCount: 2, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.remoteStatus"], + defectCount: 1, + defectTags: ["TypeError"], + interruptionCount: 0, + }); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 860fc8075b30..c238154f58c7 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -26,6 +27,91 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; +const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; + +function boundedDiagnosticValue(value: string): string { + return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); +} + +function diagnosticValueTag(value: unknown): string { + try { + if ( + typeof value === "object" && + value !== null && + "_tag" in value && + typeof value._tag === "string" + ) { + return boundedDiagnosticValue(value._tag); + } + if (value instanceof Error) { + return boundedDiagnosticValue(value.name); + } + return typeof value; + } catch { + return "Uninspectable"; + } +} + +function diagnosticFailureOperation(value: unknown): string | undefined { + try { + if ( + typeof value === "object" && + value !== null && + "operation" in value && + typeof value.operation === "string" + ) { + return boundedDiagnosticValue(value.operation); + } + } catch { + return undefined; + } + return undefined; +} + +function addUniqueDiagnosticValue(values: Array, value: string | undefined): void { + if ( + value !== undefined && + values.length < MAX_FAILURE_DIAGNOSTIC_VALUES && + !values.includes(value) + ) { + values.push(value); + } +} + +export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { + const failureTags: Array = []; + const failureOperations: Array = []; + const defectTags: Array = []; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + if (Cause.isFailReason(reason)) { + failureCount += 1; + addUniqueDiagnosticValue(failureTags, diagnosticValueTag(reason.error)); + addUniqueDiagnosticValue(failureOperations, diagnosticFailureOperation(reason.error)); + continue; + } + if (Cause.isDieReason(reason)) { + defectCount += 1; + addUniqueDiagnosticValue(defectTags, diagnosticValueTag(reason.defect)); + continue; + } + interruptionCount += 1; + } + + return { + reasonCount: cause.reasons.length, + failureCount, + failureTags, + failureOperations, + defectCount, + defectTags, + interruptionCount, + }; +} interface VcsStatusChange { readonly cwd: string; @@ -318,14 +404,19 @@ export const make = Effect.gen(function* () { return activeInterval; } + const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); + if (interruptionReasons.length > 0) { + return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); + } + const consecutiveFailures = yield* Ref.updateAndGet( consecutiveFailuresRef, (count) => count + 1, ); const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); yield* Effect.logWarning("VCS remote status refresh failed", { - cwd, - detail: exit.cause.toString(), + cwdLength: cwd.length, + ...remoteRefreshFailureDiagnostics(exit.cause), consecutiveFailures, nextDelayMs: Duration.toMillis(nextDelay), }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 38aba54277c7..0f1f09729be5 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -352,6 +352,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass()("GitManagerError", { operation: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { From fc2cdeceb9ddb28c0de834b9fcb38abf68d99148 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:50:19 -0700 Subject: [PATCH 15/80] [codex] Structure preview automation boundary failures (#3436) Co-authored-by: codex --- .../preview/PreviewAutomationOwner.tsx | 399 +++++++----------- .../preview/previewAutomationErrors.ts | 169 ++++++++ .../previewAutomationRequestConsumer.test.ts | 139 +++++- .../previewAutomationRequestConsumer.ts | 68 +-- 4 files changed, 470 insertions(+), 305 deletions(-) create mode 100644 apps/web/src/components/preview/previewAutomationErrors.ts diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index e3d08ea131b4..2be143636240 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -3,19 +3,13 @@ import { useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { - EnvironmentId, type PreviewAutomationNavigateInput, type PreviewAutomationOpenInput, - PreviewAutomationOperation, type PreviewAutomationOwner as PreviewAutomationOwnerState, type PreviewAutomationRequest, type PreviewAutomationStatus, - PreviewTabId, type ScopedThreadRef, - ThreadId, - TrimmedNonEmptyString, } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; import { @@ -31,105 +25,19 @@ import { useEnvironmentConnectionState } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationOperationError, + PreviewAutomationOverlayTimeoutError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; import { createLatestPreviewAutomationRequestHandler, createPreviewAutomationRequestConsumerAtom, } from "./previewAutomationRequestConsumer"; -export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationOverlayTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError"; - } - - override get message(): string { - return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationNavigationTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - readiness: Schema.Literals(["domContentLoaded", "load"]), - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError"; - } - - override get message(): string { - return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( - "PreviewAutomationStaleOwnerError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - expectedThreadId: ThreadId, - requestedThreadId: ThreadId, - }, -) { - get responseTag() { - return "PreviewAutomationUnavailableError"; - } - - override get message(): string { - return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; - } -} - -export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( - "PreviewAutomationTargetUnavailableError", - { - requestId: TrimmedNonEmptyString, - operation: PreviewAutomationOperation, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: Schema.NullOr(PreviewTabId), - bridgeAvailable: Schema.Boolean, - }, -) { - get responseTag() { - return "PreviewAutomationTabNotFoundError"; - } - - override get message(): string { - return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; - } -} - -export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( - "PreviewAutomationRecordingNotActiveError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - }, -) { - get responseTag() { - return "PreviewAutomationExecutionError"; - } - - override get message(): string { - return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; - } -} - export function observeAutomationOwnerConnectedGeneration( previousGeneration: number | null, connectedGeneration: number | null, @@ -287,152 +195,166 @@ export function PreviewAutomationOwner(props: { const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { - if (request.threadId !== threadRef.threadId) { - throw new PreviewAutomationStaleOwnerError({ + let tabId = request.tabId ?? null; + try { + if (request.threadId !== threadRef.threadId) { + throw new PreviewAutomationStaleOwnerError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + expectedThreadId: threadRef.threadId, + requestedThreadId: request.threadId, + }); + } + const state = readThreadPreviewState(threadRef); + tabId = request.tabId ?? state.snapshot?.tabId ?? null; + const unavailableTarget = { requestId: request.requestId, + operation: request.operation, environmentId: threadRef.environmentId, - expectedThreadId: threadRef.threadId, - requestedThreadId: request.threadId, - }); - } - const state = readThreadPreviewState(threadRef); - const tabId = request.tabId ?? state.snapshot?.tabId ?? null; - const unavailableTarget = { - requestId: request.requestId, - operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - bridgeAvailable: Boolean(previewBridge), - }; - switch (request.operation) { - case "status": - return currentStatus(threadRef, visible); - case "open": { - const input = request.input as PreviewAutomationOpenInput; - let activeTabId = - (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; - if (!activeTabId) { - const result = await open({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - ...(input.url ? { url: input.url } : {}), - }, - }); - if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); + threadId: threadRef.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }; + switch (request.operation) { + case "status": + return await currentStatus(threadRef, visible); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + tabId = activeTabId; + if (!activeTabId) { + const result = await open({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + ...(input.url ? { url: input.url } : {}), + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const snapshot = result.value; + applyPreviewServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + tabId = activeTabId; + } else if (input.url && previewBridge) { + await previewBridge.navigate(activeTabId, input.url); } - const snapshot = result.value; - applyPreviewServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - } else if (input.url && previewBridge) { - await previewBridge.navigate(activeTabId, input.url); - } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); - } - await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); - return currentStatus(threadRef, input.show ?? true); - } - case "navigate": { - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( - threadRef.environmentId, - input.target ?? { kind: "url", url: input.url! }, - ); - await previewBridge.navigate(tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return currentStatus(threadRef, visible); - } - case "snapshot": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.snapshot(tabId); - case "click": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.click( - tabId, - request.input as Parameters[1], - ); - case "type": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.type( - tabId, - request.input as Parameters[1], - ); - case "press": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.press( - tabId, - request.input as Parameters[1], - ); - case "scroll": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.scroll( - tabId, - request.input as Parameters[1], - ); - case "evaluate": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.evaluate( - tabId, - request.input as Parameters[1], - ); - case "waitFor": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return previewBridge.automation.waitFor( - tabId, - request.input as Parameters[1], - ); - case "recordingStart": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); + return await currentStatus(threadRef, input.show ?? true); } - const startedAt = await startBrowserRecording(tabId); - return { - tabId, - recording: true, - startedAt, - }; - } - case "recordingStop": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + case "navigate": { + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const input = request.input as PreviewAutomationNavigateInput; + const resolution = resolveBrowserNavigationTarget( + threadRef.environmentId, + input.target ?? { kind: "url", url: input.url! }, + ); + await previewBridge.navigate(tabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return await currentStatus(threadRef, visible); } - const artifact = await stopBrowserRecording(tabId); - if (!artifact) { - throw new PreviewAutomationRecordingNotActiveError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, + case "snapshot": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.snapshot(tabId); + case "click": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.click( + tabId, + request.input as Parameters[1], + ); + case "type": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.type( + tabId, + request.input as Parameters[1], + ); + case "press": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.press( + tabId, + request.input as Parameters[1], + ); + case "scroll": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.scroll( + tabId, + request.input as Parameters[1], + ); + case "evaluate": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.evaluate( + tabId, + request.input as Parameters[1], + ); + case "waitFor": + if (!previewBridge || !tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + return await previewBridge.automation.waitFor( + tabId, + request.input as Parameters[1], + ); + case "recordingStart": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const startedAt = await startBrowserRecording(tabId); + return { tabId, - }); + recording: true, + startedAt, + }; + } + case "recordingStop": { + if (!tabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + const artifact = await stopBrowserRecording(tabId); + if (!artifact) { + throw new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + }); + } + return artifact; } - return artifact; } + } catch (cause) { + throw PreviewAutomationOperationError.fromCause({ + requestId: request.requestId, + operation: request.operation, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + cause, + }); } }, [open, threadRef, visible], @@ -448,6 +370,7 @@ export function PreviewAutomationOwner(props: { () => createPreviewAutomationRequestConsumerAtom({ requestsAtom: automationRequestsAtom, + environmentId: threadRef.environmentId, handleRequest: requestHandler.handle, respond: (response) => respondToAutomation({ diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts new file mode 100644 index 000000000000..c4ca445458cb --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -0,0 +1,169 @@ +import { + EnvironmentId, + type PreviewAutomationOwner, + PreviewAutomationOperation, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface PreviewAutomationOperationContext { + readonly requestId: PreviewAutomationRequest["requestId"]; + readonly operation: PreviewAutomationRequest["operation"]; + readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly threadId: PreviewAutomationRequest["threadId"]; + readonly tabId: Exclude | null; +} + +export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationOverlayTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationNavigationTimeoutError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + readiness: Schema.Literals(["domContentLoaded", "load"]), + timeoutMs: Schema.Int, + }, +) { + get responseTag() { + return "PreviewAutomationTimeoutError" as const; + } + + override get message(): string { + return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; + } +} + +export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( + "PreviewAutomationStaleOwnerError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + expectedThreadId: ThreadId, + requestedThreadId: ThreadId, + }, +) { + get responseTag() { + return "PreviewAutomationUnavailableError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; + } +} + +export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetUnavailableError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + bridgeAvailable: Schema.Boolean, + }, +) { + get responseTag() { + return "PreviewAutomationTabNotFoundError" as const; + } + + override get message(): string { + return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; + } +} + +export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( + "PreviewAutomationRecordingNotActiveError", + { + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: PreviewTabId, + }, +) { + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; + } +} + +export class PreviewAutomationOperationError extends Schema.TaggedErrorClass()( + "PreviewAutomationOperationError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + cause: Schema.Defect(), + }, +) { + static fromCause( + input: PreviewAutomationOperationContext & { readonly cause: unknown }, + ): PreviewAutomationOwnerError { + return isPreviewAutomationOwnerError(input.cause) + ? input.cause + : new PreviewAutomationOperationError(input); + } + + get responseTag() { + return "PreviewAutomationExecutionError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} failed on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`; + } +} + +export const PreviewAutomationOwnerError = Schema.Union([ + PreviewAutomationOverlayTimeoutError, + PreviewAutomationNavigationTimeoutError, + PreviewAutomationStaleOwnerError, + PreviewAutomationTargetUnavailableError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationOperationError, +]); +export type PreviewAutomationOwnerError = typeof PreviewAutomationOwnerError.Type; + +export const isPreviewAutomationOwnerError = Schema.is(PreviewAutomationOwnerError); + +export function serializePreviewAutomationOwnerError( + error: PreviewAutomationOwnerError, +): NonNullable { + const detail = Object.fromEntries( + Object.entries(error).filter( + ([key]) => + key !== "_tag" && key !== "cause" && key !== "name" && key !== "message" && key !== "stack", + ), + ); + return { + _tag: error.responseTag, + message: error.message, + ...(Object.keys(detail).length === 0 ? {} : { detail }), + }; +} diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts index 5cc89c00e9c0..905a014d5af1 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts @@ -1,19 +1,33 @@ -import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; -import { ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + PreviewTabId, + ThreadId, +} from "@t3tools/contracts"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; +import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; import { createPreviewAutomationRequestConsumerAtom, serializePreviewAutomationError, } from "./previewAutomationRequestConsumer"; -const request = (requestId: string): PreviewAutomationRequest => ({ +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); +const tabId = PreviewTabId.make("tab-1"); + +const request = ( + requestId: string, + overrides: Partial = {}, +): PreviewAutomationRequest => ({ requestId, - threadId: ThreadId.make("thread-1"), + threadId, operation: "status", input: {}, timeoutMs: 15_000, + ...overrides, }); describe("previewAutomationRequestConsumer", () => { @@ -30,6 +44,7 @@ describe("previewAutomationRequestConsumer", () => { }); const consumerAtom = createPreviewAutomationRequestConsumerAtom({ requestsAtom, + environmentId, handleRequest, respond, label: "test:preview-automation-consumer", @@ -56,6 +71,7 @@ describe("previewAutomationRequestConsumer", () => { const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); const consumerAtom = createPreviewAutomationRequestConsumerAtom({ requestsAtom, + environmentId, handleRequest: async () => undefined, respond, label: "test:preview-automation-initial-request", @@ -69,33 +85,112 @@ describe("previewAutomationRequestConsumer", () => { registry.dispose(); }); - it("preserves typed automation errors in responses", () => { - const error = new Error("No preview tab"); - error.name = "PreviewAutomationTabNotFoundError"; + it("preserves tagged automation errors and their structured diagnostics", () => { + const error = new PreviewAutomationTargetUnavailableError({ + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + bridgeAvailable: false, + }); - expect(serializePreviewAutomationError(error)).toEqual({ + expect( + serializePreviewAutomationError(error, { + requestId: "request-1", + operation: "click", + environmentId, + threadId, + tabId, + }), + ).toEqual({ _tag: "PreviewAutomationTabNotFoundError", - message: "No preview tab", + message: + "Preview automation target for click request request-1 is unavailable on environment environment-1 thread thread-1 (tab tab-1, bridge unavailable).", + detail: { + requestId: "request-1", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + bridgeAvailable: false, + }, }); }); - it("serializes structured automation context without leaking causes", () => { - const error = Object.assign(new Error("Preview target unavailable"), { - name: "PreviewAutomationTargetUnavailableError", - _tag: "PreviewAutomationTargetUnavailableError", - responseTag: "PreviewAutomationTabNotFoundError", - requestId: "request-1", - threadId: "thread-1", - cause: new Error("private bridge failure"), - }); + it("correlates unexpected failures without exposing cause details", () => { + const cause = new Error("private bridge token: preview-secret"); + const context = { + requestId: "request-2", + operation: "snapshot" as const, + environmentId, + threadId, + tabId, + }; + const response = serializePreviewAutomationError(cause, context); - expect(serializePreviewAutomationError(error)).toEqual({ - _tag: "PreviewAutomationTabNotFoundError", - message: "Preview target unavailable", + expect(response).toEqual({ + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).", detail: { - requestId: "request-1", + requestId: "request-2", + operation: "snapshot", + environmentId: "environment-1", threadId: "thread-1", + tabId: "tab-1", }, }); + expect(JSON.stringify(response)).not.toContain("preview-secret"); + }); + + it("sanitizes unexpected handler failures at the response boundary", async () => { + const requestsAtom = Atom.make>( + AsyncResult.initial(false), + ); + const responses: PreviewAutomationResponse[] = []; + const consumerAtom = createPreviewAutomationRequestConsumerAtom({ + requestsAtom, + environmentId, + handleRequest: async () => { + throw new Error("desktop IPC secret: do-not-return"); + }, + respond: async (response) => { + responses.push(response); + }, + label: "test:preview-automation-failure-boundary", + }); + const registry = AtomRegistry.make(); + registry.mount(consumerAtom); + + registry.set( + requestsAtom, + AsyncResult.success( + request("request-failed", { + operation: "click", + tabId, + }), + ), + ); + + await vi.waitFor(() => expect(responses).toHaveLength(1)); + expect(responses[0]).toEqual({ + requestId: "request-failed", + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: + "Preview automation click request request-failed failed on environment environment-1 thread thread-1 (tab tab-1).", + detail: { + requestId: "request-failed", + operation: "click", + environmentId: "environment-1", + threadId: "thread-1", + tabId: "tab-1", + }, + }, + }); + expect(JSON.stringify(responses[0])).not.toContain("do-not-return"); + registry.dispose(); }); }); diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts index 5cf5590335f9..37983b0255ea 100644 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts @@ -1,6 +1,16 @@ -import type { PreviewAutomationRequest, PreviewAutomationResponse } from "@t3tools/contracts"; +import type { + PreviewAutomationOwner, + PreviewAutomationRequest, + PreviewAutomationResponse, +} from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { + PreviewAutomationOperationError, + type PreviewAutomationOperationContext, + serializePreviewAutomationOwnerError, +} from "./previewAutomationErrors"; + type AutomationRequestResult = AsyncResult.AsyncResult; type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise; @@ -19,54 +29,16 @@ export function createLatestPreviewAutomationRequestHandler(initial: AutomationR export function serializePreviewAutomationError( error: unknown, + context: PreviewAutomationOperationContext, ): NonNullable { - if (error instanceof Error) { - const explicitDetail = - "detail" in error && (error as { detail?: unknown }).detail !== undefined - ? (error as { detail?: unknown }).detail - : undefined; - const structuralDetail = - "_tag" in error && - typeof (error as { _tag?: unknown })._tag === "string" && - (error as { _tag: string })._tag.startsWith("PreviewAutomation") - ? Object.fromEntries( - Object.entries(error).filter( - ([key]) => - key !== "_tag" && - key !== "cause" && - key !== "name" && - key !== "message" && - key !== "stack" && - key !== "detail" && - key !== "responseTag", - ), - ) - : undefined; - const detail = explicitDetail ?? structuralDetail; - const responseTag = - "responseTag" in error && - typeof (error as { responseTag?: unknown }).responseTag === "string" && - (error as { responseTag: string }).responseTag.startsWith("PreviewAutomation") - ? (error as { responseTag: string }).responseTag - : undefined; - return { - _tag: - responseTag ?? - (error.name.startsWith("PreviewAutomation") - ? error.name - : "PreviewAutomationExecutionError"), - message: error.message, - ...(detail === undefined ? {} : { detail }), - }; - } - return { - _tag: "PreviewAutomationExecutionError", - message: String(error), - }; + return serializePreviewAutomationOwnerError( + PreviewAutomationOperationError.fromCause({ ...context, cause: error }), + ); } export function createPreviewAutomationRequestConsumerAtom(options: { readonly requestsAtom: Atom.Atom>; + readonly environmentId: PreviewAutomationOwner["environmentId"]; readonly handleRequest: (request: PreviewAutomationRequest) => Promise; readonly respond: (response: PreviewAutomationResponse) => Promise; readonly label: string; @@ -89,7 +61,13 @@ export function createPreviewAutomationRequestConsumerAtom(options: { options.respond({ requestId: request.requestId, ok: false, - error: serializePreviewAutomationError(error), + error: serializePreviewAutomationError(error, { + requestId: request.requestId, + operation: request.operation, + environmentId: options.environmentId, + threadId: request.threadId, + tabId: request.tabId ?? null, + }), }), ); }; From b51aef10c4f21c35f195b81e548c052041a15051 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:22 -0700 Subject: [PATCH 16/80] [codex] Preserve cloud disconnect diagnostics (#3437) Co-authored-by: codex --- apps/server/src/cli/connect.test.ts | 55 ++++++++++++++++++++- apps/server/src/cli/connect.ts | 76 ++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 5fce3bc1cd7d..70b0329ac90a 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,9 +1,14 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as References from "effect/References"; -import { acquireRelayClientForLink } from "./connect.ts"; +import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts"; const managedExecutable = { status: "available", @@ -100,3 +105,51 @@ it.effect("reuses an available relay client executable without prompting", () => assert.equal(promptCalls, 0); }), ); + +it.effect("keeps disconnect causes in structured logs and out of console warnings", () => { + const warnings: ReadonlyArray[] = []; + const logs: Readonly>[] = []; + const testConsole = { + ...globalThis.console, + warn: (...args: ReadonlyArray) => { + warnings.push(args); + }, + } satisfies Console.Console; + const logger = Logger.make(({ fiber }) => { + logs.push(fiber.getRef(References.CurrentLogAnnotations)); + }); + const liveFailure = "live unlink private diagnostic"; + const relayFailure = "relay revoke private diagnostic"; + + return reportCloudDisconnectResults({ + clearAuthorization: true, + liveResult: { + status: "failed", + cause: Cause.fail(new Error(liveFailure)), + }, + relayResult: Exit.failCause(Cause.die(new Error(relayFailure))), + }).pipe( + Effect.provideService(Console.Console, testConsole), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + Effect.tap(() => + Effect.sync(() => { + assert.lengthOf(warnings, 2); + const warningText = warnings.flat().map(String).join("\n"); + assert.include(warningText, "running server could not stop its tunnel"); + assert.include(warningText, "Could not revoke the relay-side environment record"); + assert.notInclude(warningText, liveFailure); + assert.notInclude(warningText, relayFailure); + assert.deepEqual( + logs.map(({ operation, clearAuthorization }) => ({ operation, clearAuthorization })), + [ + { operation: "live-server-unlink", clearAuthorization: true }, + { operation: "relay-environment-unlink", clearAuthorization: true }, + ], + ); + const loggedCauses = logs.map((log) => String(log.cause)).join("\n"); + assert.include(loggedCauses, liveFailure); + assert.include(loggedCauses, relayFailure); + }), + ), + ); +}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 314680b0d801..3ce53391fa64 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -7,6 +7,7 @@ import { import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -179,7 +180,7 @@ const withCloudCliSessionToken = ( type LiveCloudActionResult = | { readonly status: "not-running" } | { readonly status: "succeeded" } - | { readonly status: "failed"; readonly cause: unknown }; + | { readonly status: "failed"; readonly cause: Cause.Cause }; const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { const config = yield* ServerConfig.ServerConfig; @@ -211,6 +212,21 @@ type RelayUnlinkResult = | { readonly status: "revoked" } | { readonly status: "not-linked" }; +type CloudDisconnectOperation = "live-server-unlink" | "relay-environment-unlink"; + +const logCloudDisconnectFailure = ( + operation: CloudDisconnectOperation, + clearAuthorization: boolean, + cause: Cause.Cause, +) => + Effect.logWarning("T3 Connect disconnect operation failed.").pipe( + Effect.annotateLogs({ + operation, + clearAuthorization, + cause: Cause.pretty(cause), + }), + ); + const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () { const tokens = yield* CliTokenManager.CloudCliTokenManager; const token = yield* tokens.getExisting; @@ -236,6 +252,42 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f : ({ status: "not-linked" } satisfies RelayUnlinkResult); }); +export const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_results")( + function* (input: { + readonly clearAuthorization: boolean; + readonly liveResult: LiveCloudActionResult; + readonly relayResult: Exit.Exit; + }) { + if (input.liveResult.status === "failed") { + yield* logCloudDisconnectFailure( + "live-server-unlink", + input.clearAuthorization, + input.liveResult.cause, + ); + yield* Console.warn( + "T3 Connect is disabled, but the running server could not stop its tunnel.\nRestart that server to stop the connector.", + ); + } else { + yield* Console.log("T3 Connect is disabled locally."); + } + + if (Exit.isFailure(input.relayResult)) { + yield* logCloudDisconnectFailure( + "relay-environment-unlink", + input.clearAuthorization, + input.relayResult.cause, + ); + yield* Console.warn( + input.clearAuthorization + ? "Could not revoke the relay-side environment record before signing out.\nThe stored CLI authorization was still removed locally." + : "Could not revoke the relay-side environment record yet.\nRun `t3 connect unlink` again when the relay is reachable.", + ); + } else if (input.relayResult.value.status === "revoked") { + yield* Console.log("Revoked the relay-side environment record."); + } + }, +); + const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { readonly clearAuthorization: boolean; }) { @@ -249,23 +301,11 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { yield* tokens.clear; } - if (liveResult.status === "failed") { - yield* Console.warn( - `T3 Connect is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`, - ); - } else { - yield* Console.log("T3 Connect is disabled locally."); - } - - if (Exit.isFailure(relayResult)) { - yield* Console.warn( - options.clearAuthorization - ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.` - : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 connect unlink\` again when the relay is reachable.`, - ); - } else if (relayResult.value.status === "revoked") { - yield* Console.log("Revoked the relay-side environment record."); - } + yield* reportCloudDisconnectResults({ + clearAuthorization: options.clearAuthorization, + liveResult, + relayResult, + }); if (options.clearAuthorization) { yield* Console.log("Signed out of T3 Connect locally."); From b6c590af389d09400fa4f99a56ffc33e9ed4ebf1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:27 -0700 Subject: [PATCH 17/80] [codex] Fix desktop preview event delivery errors (#3435) Co-authored-by: codex --- apps/desktop/src/ipc/methods/preview.ts | 29 ++++------ apps/desktop/src/preview/Manager.test.ts | 74 +++++++++++++++++++++++- apps/desktop/src/preview/Manager.ts | 30 ++++++++-- 3 files changed, 108 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 1994c2700244..2abf53ac2843 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -19,37 +19,30 @@ import { PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; -import { BrowserWindow } from "electron"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as NodeURL from "node:url"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; -const broadcast = (channel: string, ...args: ReadonlyArray): void => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(channel, ...args); - } - } -}; - export const installPreviewEventForwarding = Effect.fn( "desktop.ipc.preview.installEventForwarding", )(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; const manager = yield* PreviewManager.PreviewManager; - yield* manager.subscribeStateChanges((tabId, state) => { - broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); - }); - yield* manager.subscribeRecordingFrames((frame) => { - broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); - }); - yield* manager.subscribePointerEvents((event) => { - broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); - }); + yield* manager.subscribeStateChanges((tabId, state) => + electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), + ); + yield* manager.subscribeRecordingFrames((frame) => + electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), + ); + yield* manager.subscribePointerEvents((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), + ); }); export const createTab = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index cc83d5f2e373..acb0d783a82d 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -13,6 +14,7 @@ import { TestClock } from "effect/testing"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; @@ -130,6 +132,72 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { + const loggedErrors: Array = []; + const logger = Logger.make(({ message }) => { + for (const value of Array.isArray(message) ? message : [message]) { + if (typeof value === "object" && value !== null && "cause" in value) { + loggedErrors.push(Cause.squash(value.cause as Cause.Cause)); + } + } + }); + const deliveryError = new ElectronWindow.ElectronWindowOperationError({ + operation: "send-window-message", + platform: "darwin", + windowId: 42, + channel: "preview:state-change", + cause: new Error("renderer unavailable"), + }); + const delivered = vi.fn(); + + return withManager((manager) => + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.die(deliveryError)); + yield* manager.subscribeStateChanges((tabId, state) => + Effect.sync(() => { + delivered(tabId, state); + }), + ); + + const state = yield* manager.createTab("tab_listener_failure"); + + expect(delivered).toHaveBeenCalledOnce(); + expect(delivered).toHaveBeenCalledWith("tab_listener_failure", state); + expect(loggedErrors).toHaveLength(1); + expect(loggedErrors[0]).toBeInstanceOf(ElectronWindow.ElectronWindowOperationError); + expect(loggedErrors[0]).toMatchObject({ + operation: "send-window-message", + windowId: 42, + channel: "preview:state-change", + }); + }), + ).pipe( + Effect.provide( + Logger.layer([logger], { + mergeWithExisting: false, + }), + ), + ); + }); + + effectIt.effect("does not swallow state listener interruption", () => + withManager((manager) => + Effect.gen(function* () { + const exit = yield* Effect.scoped( + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.interrupt); + return yield* Effect.exit(manager.createTab("tab_interrupted_listener")); + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + } + }), + ), + ); + effectIt.effect("queues navigation until the webview registers", () => withManager((manager) => Effect.gen(function* () { @@ -411,7 +479,11 @@ describe("PreviewManager", () => { }, } as never); - yield* manager.subscribePointerEvents((event) => activity.push(event.phase)); + yield* manager.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); const click = yield* manager diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index bb3e1fcef931..6fd65cd25b5b 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -281,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; }; -type Listener = (tabId: string, state: PreviewTabState) => void; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; +type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -313,7 +313,7 @@ interface BrowserDiagnostics { readonly requests: ReadonlyMap; } -type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; +type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; interface ExpectedAgentInput { readonly signal: PreviewInputSignal; @@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return copy; }; + const deliverEvent = ( + eventKind: "state-change" | "recording-frame" | "pointer-event", + tabId: string, + delivery: () => Effect.Effect, + ) => + Effect.suspend(delivery).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("Desktop preview event listener failed.", { + eventKind, + tabId, + cause, + }), + ), + ); + const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { const listeners = yield* Ref.get(listenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), + (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)), { discard: true }, ); }); @@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + (listener) => + deliverEvent("recording-frame", frame.tabId, () => listener(frame)), { discard: true }, ); } @@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), + (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), { discard: true }, ); }); From 6d8d995621065e0513def2fa28bf0f7d8b54dd90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:31 -0700 Subject: [PATCH 18/80] [codex] Correlate protocol request failures (#3433) Co-authored-by: codex --- packages/effect-acp/src/_internal/shared.ts | 2 +- packages/effect-acp/src/errors.test.ts | 2 + packages/effect-acp/src/errors.ts | 66 ++++++++++++- packages/effect-acp/src/protocol.test.ts | 74 +++++++++++++- packages/effect-acp/src/protocol.ts | 98 +++++++++++++------ .../effect-codex-app-server/src/errors.ts | 16 ++- .../src/protocol.test.ts | 53 +++++++++- .../effect-codex-app-server/src/protocol.ts | 62 +++++++----- 8 files changed, 314 insertions(+), 59 deletions(-) diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index 7e43bbf8831e..f54f81e2a083 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -12,7 +12,7 @@ export const callRpc = ( ): Effect.Effect => effect.pipe( Effect.catchIf(isError, (error) => - Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), + Effect.fail(AcpError.AcpRequestError.fromProtocolError(error, { method })), ), Effect.catchTags({ RpcClientError: (cause) => diff --git a/packages/effect-acp/src/errors.test.ts b/packages/effect-acp/src/errors.test.ts index 5187fabf5d20..a54c5af48431 100644 --- a/packages/effect-acp/src/errors.test.ts +++ b/packages/effect-acp/src/errors.test.ts @@ -51,6 +51,8 @@ describe("effect-acp errors", () => { code: -32602, errorMessage: "Invalid params", data: { field: "sessionId" }, + method: "session/load", + operation: "receive-response", }); }); }); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index b3c0dee62942..3fe0a4690013 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -5,8 +5,11 @@ import * as AcpSchema from "./_generated/schema.gen.ts"; export const AcpRequestOperation = Schema.Literals([ "decode-extension-request-payload", + "encode-extension-response", "handle-request", "handle-extension-request", + "receive-response", + "receive-streaming-response", ]); export type AcpRequestOperation = typeof AcpRequestOperation.Type; @@ -65,6 +68,7 @@ const schemaIssueDiagnostics = (root: SchemaIssue.Issue): AcpSchemaIssueDiagnost export interface AcpRequestDiagnostics { readonly method?: string; + readonly requestId?: string; readonly operation?: AcpRequestOperation; readonly cause?: unknown; readonly issueCount?: number; @@ -109,6 +113,7 @@ export class AcpProtocolParseError extends Schema.TaggedErrorClass()( @@ -167,6 +185,7 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( errorMessage: Schema.String, data: Schema.optional(Schema.Unknown), method: Schema.optionalKey(Schema.String), + requestId: Schema.optionalKey(Schema.String), operation: Schema.optionalKey(AcpRequestOperation), issueCount: Schema.optionalKey(Schema.Number), issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)), @@ -177,14 +196,59 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( return this.errorMessage; } - static fromProtocolError(error: AcpSchema.Error) { + static fromProtocolError( + error: AcpSchema.Error, + context: { + readonly method: string; + readonly requestId?: string; + readonly cause?: unknown; + }, + ) { return new AcpRequestError({ code: error.code, errorMessage: error.message, ...(error.data !== undefined ? { data: error.data } : {}), + method: context.method, + ...(context.requestId === undefined ? {} : { requestId: context.requestId }), + operation: "receive-response", + cause: context.cause ?? error, }); } + static fromExtensionResponseFailure(method: string, requestId: string, cause: unknown) { + return AcpRequestError.internalError("Extension request failed", undefined, { + method, + requestId, + operation: "receive-response", + cause, + }); + } + + static fromExtensionResponseEncodingError( + method: string, + requestId: string, + cause: AcpProtocolParseError, + ) { + return AcpRequestError.internalError("Internal error", undefined, { + method, + requestId, + operation: "encode-extension-response", + cause, + }); + } + + static unsupportedStreamingResponse(method: string, requestId: string) { + return AcpRequestError.internalError( + "Streaming extension responses are not supported", + undefined, + { + method, + requestId, + operation: "receive-streaming-response", + }, + ); + } + static fromCoreHandlerError(error: AcpError, method: string) { if (error._tag === "AcpRequestError") { return error; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index c8e03dd72351..ece068dfc882 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -260,15 +260,33 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); assert.equal(bigintError.operation, "encode-message"); + assert.equal(bigintError.method, "x/test"); assert.instanceOf(bigintError.cause, TypeError); - assert.equal(bigintError.message, "ACP protocol operation 'encode-message' failed."); + assert.equal( + bigintError.message, + "ACP protocol operation 'encode-message' failed for method 'x/test'.", + ); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, AcpError.AcpProtocolParseError); assert.equal(circularError.operation, "encode-message"); + assert.equal(circularError.method, "x/test"); assert.instanceOf(circularError.cause, TypeError); + + const requestError = yield* transport.request("x/request", 1n).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected request encoding to fail"), + }), + ); + assert.instanceOf(requestError, AcpError.AcpProtocolParseError); + assert.deepInclude(requestError, { + operation: "encode-message", + method: "x/request", + requestId: "1", + }); }), ); @@ -310,6 +328,60 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("correlates extension response errors with the originating request", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const response = yield* transport + .request("x/private", { hello: "world" }) + .pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + id: 1, + error: { + _tag: "Cause", + code: -32602, + message: "Invalid params", + data: [ + { + _tag: "Fail", + error: { + code: -32602, + message: "Invalid params", + data: { field: "hello" }, + }, + }, + ], + }, + })}\n`, + ), + ); + + const error = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected extension request to fail"), + }), + ); + assert.instanceOf(error, AcpError.AcpRequestError); + assert.deepInclude(error, { + code: -32602, + errorMessage: "Invalid params", + method: "x/private", + requestId: "1", + operation: "receive-response", + }); + }), + ); + it.effect("preserves zero-valued ids for inbound core client requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 6c3bd399028f..27c619296c0a 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -66,6 +66,11 @@ export interface AcpPatchedProtocol { readonly notify: (method: string, payload: unknown) => Effect.Effect; } +interface AcpPendingRequest { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, @@ -83,9 +88,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1n); const terminationHandled = yield* Ref.make(false); - const extPending = yield* Ref.make( - new Map>(), - ); + const extPending = yield* Ref.make(new Map()); const logProtocol = (event: AcpProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -109,13 +112,17 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi payload: message, }); + const method = message._tag === "Request" ? message.tag : undefined; + const encodedRequestId = + message._tag === "Request" + ? message.id + : "requestId" in message + ? message.requestId + : undefined; + const requestId = encodedRequestId === "" ? undefined : encodedRequestId; const encoded = yield* Effect.try({ try: () => parser.encode(message), - catch: (cause) => - new AcpError.AcpProtocolParseError({ - operation: "encode-message", - cause, - }), + catch: (cause) => AcpError.AcpProtocolParseError.fromEncodingError(method, requestId, cause), }); if (encoded) { @@ -131,16 +138,16 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const resolveExtPending = ( requestId: string, - onFound: (deferred: Deferred.Deferred) => Effect.Effect, + onFound: (pendingRequest: AcpPendingRequest) => Effect.Effect, ) => Ref.modify(extPending, (pending) => { - const deferred = pending.get(requestId); - if (!deferred) { + const pendingRequest = pending.get(requestId); + if (!pendingRequest) { return [Effect.void, pending] as const; } const next = new Map(pending); next.delete(requestId); - return [onFound(deferred), next] as const; + return [onFound(pendingRequest), next] as const; }).pipe(Effect.flatten); const removeExtPending = (requestId: string) => @@ -154,15 +161,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }); const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) => - resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error)); + resolveExtPending(requestId, ({ deferred }) => Deferred.fail(deferred, error)); const completeExtPendingSuccess = (requestId: string, value: unknown) => - resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value)); + resolveExtPending(requestId, ({ deferred }) => Deferred.succeed(deferred, value)); const failAllExtPending = (error: AcpError.AcpError) => Ref.getAndSet(extPending, new Map()).pipe( Effect.flatMap((pending) => - Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { + Effect.forEach([...pending.values()], ({ deferred }) => Deferred.fail(deferred, error), { discard: true, }), ), @@ -303,7 +310,26 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi if (!options.serverRequestMethods.has(message.tag)) { return handleExtRequest(message).pipe( - Effect.catch(() => respondWithError(message.id, AcpError.AcpRequestError.internalError())), + Effect.catchTags({ + AcpProtocolParseError: (error) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + method: message.tag, + requestId: message.id, + operation: error.operation, + }), + Effect.andThen( + respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionResponseEncodingError( + message.tag, + message.id, + error, + ), + ), + ), + ), + }), Effect.asVoid, ); } @@ -314,7 +340,8 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => Ref.get(extPending).pipe( Effect.flatMap((pending) => { - if (!pending.has(message.requestId)) { + const pendingRequest = pending.get(message.requestId); + if (!pendingRequest) { return Queue.offer(clientQueue, message).pipe(Effect.asVoid); } if (message.exit._tag === "Success") { @@ -324,12 +351,20 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi if (failure && isProtocolError(failure.error)) { return completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.fromProtocolError(failure.error), + AcpError.AcpRequestError.fromProtocolError(failure.error, { + method: pendingRequest.method, + requestId: message.requestId, + cause: message.exit.cause, + }), ); } return completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.internalError("Extension request failed"), + AcpError.AcpRequestError.fromExtensionResponseFailure( + pendingRequest.method, + message.requestId, + message.exit.cause, + ), ); }), ); @@ -344,16 +379,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi return handleExitEncoded(message); case "Chunk": return Ref.get(extPending).pipe( - Effect.flatMap((pending) => - pending.has(message.requestId) + Effect.flatMap((pending) => { + const pendingRequest = pending.get(message.requestId); + return pendingRequest ? completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.internalError( - "Streaming extension responses are not supported", + AcpError.AcpRequestError.unsupportedStreamingResponse( + pendingRequest.method, + message.requestId, ), ) - : Queue.offer(clientQueue, message).pipe(Effect.asVoid), - ), + : Queue.offer(clientQueue, message).pipe(Effect.asVoid); + }), ); case "Defect": case "ClientProtocolError": @@ -401,6 +438,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi payload: { operation: error.operation, ...(error.method === undefined ? {} : { method: error.method }), + ...(error.requestId === undefined ? {} : { requestId: error.requestId }), ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), ...(error.maximumPathDepth === undefined @@ -494,18 +532,16 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi (current) => [current, current + 1n] as const, ); const deferred = yield* Deferred.make(); - yield* Ref.update(extPending, (pending) => new Map(pending).set(String(requestId), deferred)); + yield* Ref.update(extPending, (pending) => + new Map(pending).set(String(requestId), { deferred, method }), + ); yield* offerOutgoing({ _tag: "Request", id: String(requestId), tag: method, payload, headers: [], - }).pipe( - Effect.catch((error) => - removeExtPending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), - ), - ); + }).pipe(Effect.tapError(() => removeExtPending(String(requestId)))); return yield* Deferred.await(deferred).pipe( Effect.onInterrupt(() => removeExtPending(String(requestId))), ); diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 2f769f47de2d..2559bba618c7 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -5,6 +5,7 @@ export const CodexAppServerRequestOperation = Schema.Literals([ "decode-payload", "encode-payload", "handle-request", + "receive-response", ]); export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; @@ -83,6 +84,7 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { export interface CodexAppServerRequestDiagnostics { readonly method?: string; + readonly requestId?: string; readonly operation?: CodexAppServerRequestOperation; readonly cause?: unknown; readonly issueCount?: number; @@ -154,6 +156,7 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass { const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, CodexError.CodexAppServerProtocolParseError); assert.equal(bigintError.operation, "encode-wire-message"); + assert.equal(bigintError.method, "x/test"); assert.exists(bigintError.cause); assert.equal( bigintError.message, - "Codex App Server protocol operation 'encode-wire-message' failed.", + "Codex App Server protocol operation 'encode-wire-message' failed for method 'x/test'.", ); const circular: Record = {}; @@ -223,7 +224,57 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, CodexError.CodexAppServerProtocolParseError); assert.equal(circularError.operation, "encode-wire-message"); + assert.equal(circularError.method, "x/test"); assert.exists(circularError.cause); + + const requestError = yield* transport.request("x/request", 1n).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected request encoding to fail"), + }), + ); + assert.instanceOf(requestError, CodexError.CodexAppServerProtocolParseError); + assert.deepInclude(requestError, { + operation: "encode-wire-message", + method: "x/request", + requestId: "1", + }); + }), + ); + + it.effect("correlates response errors with the originating request", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ stdio }); + + const response = yield* transport.request("thread/start", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encodeJsonl({ + id: 1, + error: { + code: -32602, + message: "Invalid params", + data: { field: "cwd" }, + }, + }), + ); + + const error = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected Codex App Server request to fail"), + }), + ); + assert.instanceOf(error, CodexError.CodexAppServerRequestError); + assert.deepInclude(error, { + code: -32602, + errorMessage: "Invalid params", + method: "thread/start", + requestId: "1", + operation: "receive-response", + }); }), ); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index c0f07f95a5ae..fbf173cbc5e6 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -67,6 +67,11 @@ export interface CodexAppServerPatchedProtocol { ) => Effect.Effect; } +interface CodexAppServerPendingRequest { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -94,9 +99,21 @@ const encodeWireMessage = ( ): Effect.Effect => encodeJsonString(message).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.mapError((cause) => - CodexError.CodexAppServerProtocolParseError.fromSchemaError("encode-wire-message", cause), - ), + Effect.mapError((cause) => { + const method = typeof message.method === "string" ? message.method : undefined; + const requestId = + typeof message.id === "string" || typeof message.id === "number" + ? String(message.id) + : undefined; + return CodexError.CodexAppServerProtocolParseError.fromSchemaError( + "encode-wire-message", + cause, + { + ...(method === undefined ? {} : { method }), + ...(requestId === undefined ? {} : { requestId }), + }, + ); + }), ); const decodeWireMessage = ( @@ -138,9 +155,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const outgoing = yield* Queue.unbounded>(); const incomingNotifications = yield* Queue.unbounded(); const incomingRequests = yield* Queue.unbounded(); - const pending = yield* Ref.make( - new Map>(), - ); + const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); const remainder = yield* Ref.make(""); const terminationHandled = yield* Ref.make(false); @@ -161,7 +176,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const failAllPending = (error: CodexError.CodexAppServerError) => Ref.get(pending).pipe( Effect.flatMap((current) => - Effect.forEach([...current.values()], (deferred) => Deferred.fail(deferred, error), { + Effect.forEach([...current.values()], ({ deferred }) => Deferred.fail(deferred, error), { discard: true, }), ), @@ -214,18 +229,16 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const resolvePending = ( requestId: string, - handler: ( - deferred: Deferred.Deferred, - ) => Effect.Effect, + handler: (pendingRequest: CodexAppServerPendingRequest) => Effect.Effect, ) => Ref.modify(pending, (current) => { - const deferred = current.get(requestId); - if (!deferred) { + const pendingRequest = current.get(requestId); + if (!pendingRequest) { return [Effect.void, current] as const; } const next = new Map(current); next.delete(requestId); - return [handler(deferred), next] as const; + return [handler(pendingRequest), next] as const; }).pipe(Effect.flatten); const respond = (requestId: string | number, result: unknown) => @@ -240,14 +253,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const requestId = String(response.id); const protocolError = response.error; if (protocolError !== undefined) { - return resolvePending(requestId, (deferred) => + return resolvePending(requestId, ({ deferred, method }) => Deferred.fail( deferred, - CodexError.CodexAppServerRequestError.fromProtocolError(protocolError), + CodexError.CodexAppServerRequestError.fromProtocolError( + protocolError, + method, + requestId, + ), ), ); } - return resolvePending(requestId, (deferred) => Deferred.succeed(deferred, response.result)); + return resolvePending(requestId, ({ deferred }) => + Deferred.succeed(deferred, response.result), + ); }; const handleRequest = (request: CodexAppServerIncomingRequest) => @@ -322,6 +341,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa payload: { operation: error.operation, ...(error.method === undefined ? {} : { method: error.method }), + ...(error.requestId === undefined ? {} : { requestId: error.requestId }), ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }), ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }), ...(error.maximumPathDepth === undefined @@ -375,16 +395,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa (current) => [current, current + 1] as const, ); const deferred = yield* Deferred.make(); - yield* Ref.update(pending, (current) => new Map(current).set(String(requestId), deferred)); + yield* Ref.update(pending, (current) => + new Map(current).set(String(requestId), { deferred, method }), + ); yield* offerOutgoing({ id: requestId, method, ...(payload !== undefined ? { params: payload } : {}), - }).pipe( - Effect.catch((error) => - removePending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), - ), - ); + }).pipe(Effect.tapError(() => removePending(String(requestId)))); return yield* Deferred.await(deferred).pipe( Effect.onInterrupt(() => removePending(String(requestId))), ); From dd48bfd98c440029c1ecaf946916105850cfd0ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:36 -0700 Subject: [PATCH 19/80] [codex] Structure server settings failures (#3376) Co-authored-by: codex --- apps/server/src/serverRuntimeStartup.ts | 4 +- apps/server/src/serverSettings.test.ts | 57 ++++++++++++ apps/server/src/serverSettings.ts | 115 ++++++++++++++---------- packages/contracts/src/settings.ts | 27 +++++- 4 files changed, 150 insertions(+), 53 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cbdf58c4d676..b52b577c5b5a 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -327,7 +327,9 @@ export const make = Effect.gen(function* () { Effect.catch((error) => Effect.logWarning("failed to start server settings runtime", { path: error.settingsPath, - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, cause: error.cause, }), ), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 87feee669ec0..504d99e18def 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; @@ -32,7 +33,63 @@ const makeServerSettingsLayer = () => ), ); +const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => + Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.fail(cause), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), + ); + it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("preserves context when reading a provider environment secret fails", () => { + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "provider environment secret", + description: "Secret backend unavailable.", + }); + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: "provider environment secret", + cause: platformCause, + }); + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-secret-failure-test-", + }), + ); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(configLayer), + ); + + return Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}', + ); + + const error = yield* Effect.flip(serverSettings.getSettings); + + assert.deepInclude(error, { + _tag: "ServerSettingsError", + operation: "read-secret", + providerInstanceId: "codex_personal", + environmentVariable: "OPENROUTER_API_KEY", + }); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(settingsLayer)); + }); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a5fcdc30c022..4119a72640fe 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -40,7 +40,6 @@ import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -67,7 +66,7 @@ const normalizeServerSettings = ( (cause) => new ServerSettingsError({ settingsPath: "", - detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + operation: "normalize", cause, }), ), @@ -277,7 +276,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to check settings file existence", + operation: "check-exists", cause, }), ), @@ -288,7 +287,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to read settings file", + operation: "read-file", cause, }), ), @@ -305,6 +304,7 @@ const make = Effect.gen(function* () { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(decoded.cause), + cause: decoded.cause, }); return DEFAULT_SERVER_SETTINGS; } @@ -318,13 +318,6 @@ const make = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); - const toSettingsError = (detail: string, cause: unknown) => - new ServerSettingsError({ - settingsPath, - detail, - cause, - }); - const materializeProviderEnvironmentSecrets = ( settings: ServerSettings, ): Effect.Effect => @@ -343,11 +336,15 @@ const make = Effect.gen(function* () { const secret = yield* secretStore .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to read sensitive environment variable ${variable.name}`, - cause, - ), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), ), ); environment.push({ @@ -382,13 +379,18 @@ const make = Effect.gen(function* () { for (const variable of instance.environment) { const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (!variable.sensitive) { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push(redactProviderEnvironmentVariable(variable)); continue; } @@ -396,22 +398,32 @@ const make = Effect.gen(function* () { nextSecretKeys.add(secretName); if (!variable.valueRedacted) { if (variable.value.length > 0) { - yield* secretStore - .set(secretName, textEncoder.encode(variable.value)) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to persist environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.set(secretName, textEncoder.encode(variable.value)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push({ ...variable, value: "", valueRedacted: true }); } else { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); const { valueRedacted: _omit, ...rest } = variable; environment.push(rest); } @@ -431,16 +443,18 @@ const make = Effect.gen(function* () { if (!variable.sensitive) continue; const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to remove stale environment secret ${variable.name}`, + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-stale-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, cause, - ), - ), - ); + }), + ), + ); } } @@ -468,7 +482,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to write settings file", + operation: "write-file", cause, }), ), @@ -492,7 +506,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to prepare settings directory", + operation: "prepare-directory", cause, }), ), @@ -571,7 +585,10 @@ const make = Effect.gen(function* () { materializeProviderEnvironmentSecrets(settings).pipe( Effect.catch((error: ServerSettingsError) => Effect.logWarning("failed to materialize provider environment secrets", { - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, }).pipe(Effect.as(settings)), ), ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1cb57a982547..7ba267b1e721 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -414,16 +414,37 @@ export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); +export const ServerSettingsOperation = Schema.Literals([ + "normalize", + "check-exists", + "read-file", + "read-secret", + "remove-secret", + "remove-stale-secret", + "write-secret", + "write-file", + "prepare-directory", +]); +export type ServerSettingsOperation = typeof ServerSettingsOperation.Type; + export class ServerSettingsError extends Schema.TaggedErrorClass()( "ServerSettingsError", { settingsPath: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: ServerSettingsOperation, + providerInstanceId: Schema.optional(Schema.String), + environmentVariable: Schema.optional(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return `Server settings error at ${this.settingsPath}: ${this.detail}`; + const provider = + this.providerInstanceId === undefined ? "" : ` for provider ${this.providerInstanceId}`; + const variable = + this.environmentVariable === undefined + ? "" + : ` and environment variable ${this.environmentVariable}`; + return `Server settings ${this.operation} failed${provider}${variable} at ${this.settingsPath}.`; } } From faccdd4ca9511204144f3ec8d696a8993d878946 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:40 -0700 Subject: [PATCH 20/80] [codex] Preserve desktop backend log failures (#3375) Co-authored-by: codex --- .../src/app/DesktopBackendOutputLog.test.ts | 122 +++++++++++ .../src/app/DesktopBackendOutputLog.ts | 197 ++++++++++++++---- 2 files changed, 273 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src/app/DesktopBackendOutputLog.test.ts diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.test.ts b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts new file mode 100644 index 000000000000..18bba9486cbe --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts @@ -0,0 +1,122 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log"; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +}).pipe(Layer.provide(Layer.merge(Path.layer, DesktopConfig.layerTest({})))); + +const withOutputLog = ( + effect: Effect.Effect, + fileSystemLayer: Layer.Layer, + messages: Array>, +) => { + const logger = Logger.make(({ message }) => { + messages.push(Array.isArray(message) ? message : [message]); + }); + const outputLogLayer = DesktopBackendOutputLog.layer.pipe( + Layer.provide(Layer.mergeAll(fileSystemLayer, Path.layer, environmentLayer)), + Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), + ); + return effect.pipe(Effect.provide(outputLogLayer)); +}; + +const loggedError = (messages: ReadonlyArray>): unknown => + messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value) + ?.error; + +describe("DesktopBackendOutputLog", () => { + it.effect("logs setup failures with the log path and exact cause", () => { + const messages: Array> = []; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: "/Users/alice/.t3/userdata/logs", + description: "private setup diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.fail(cause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogSetupError); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to initialize the desktop backend output log at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private setup diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); + + it.effect("logs record write failures with the operation and exact cause", () => { + const messages: Array> = []; + const missingCause = PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "stat", + pathOrDescriptor: LOG_FILE_PATH, + }); + const writeCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFile", + pathOrDescriptor: LOG_FILE_PATH, + description: "private write diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.void, + stat: () => Effect.fail(missingCause), + readDirectory: () => Effect.succeed([]), + writeFile: () => Effect.fail(writeCause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogWriteError); + assert.equal(error.operation, "write-record"); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, writeCause); + assert.equal( + error.message, + `Desktop backend output log operation "write-record" failed at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private write diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts index ec29d54f44ad..cad83229deb1 100644 --- a/apps/desktop/src/app/DesktopBackendOutputLog.ts +++ b/apps/desktop/src/app/DesktopBackendOutputLog.ts @@ -19,8 +19,75 @@ export const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; interface RotatingLogFileWriter { - readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; - readonly writeText: (chunk: string) => Effect.Effect; + readonly filePath: string; + readonly writeBytes: ( + chunk: Uint8Array, + ) => Effect.Effect; + readonly writeText: ( + chunk: string, + ) => Effect.Effect; +} + +class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + value: Schema.Number, + }, +) { + override get message(): string { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +class DesktopLogFileWriterRecoveryError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterRecoveryError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + recoveryCause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to refresh desktop backend output log size after a write failure at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogSetupError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogSetupError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop backend output log at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogWriteError", + { + operation: Schema.Literals(["encode-record", "write-record"]), + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop backend output log operation "${this.operation}" failed at ${this.logFilePath}.`; + } +} + +export class DesktopBackendConsoleWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendConsoleWriteError", + { + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to mirror desktop backend output to ${this.streamName}.`; + } } export class DesktopBackendOutputLog extends Context.Service< @@ -37,18 +104,6 @@ export class DesktopBackendOutputLog extends Context.Service< } >()("@t3tools/desktop/app/DesktopBackendOutputLog") {} -class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( - "DesktopLogFileWriterConfigurationError", - { - option: Schema.Literals(["maxBytes", "maxFiles"]), - value: Schema.Number, - }, -) { - override get message(): string { - return `${this.option} must be >= 1 (received ${this.value})`; - } -} - type DesktopLogFileWriterError = | DesktopLogFileWriterConfigurationError | PlatformError.PlatformError; @@ -85,10 +140,13 @@ const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").t const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string, -): Effect.Effect => +): Effect.Effect => fileSystem.stat(filePath).pipe( Effect.map((stat) => Number(stat.size)), - Effect.orElseSucceed(() => 0), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(error), + }), ); const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { @@ -126,41 +184,52 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); const mutex = yield* Semaphore.make(1); + const recoverCurrentSize = ( + cause: PlatformError.PlatformError, + ): Effect.Effect => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.matchEffect({ + onFailure: (recoveryCause) => + Effect.fail( + new DesktopLogFileWriterRecoveryError({ + logFilePath: input.filePath, + cause, + recoveryCause, + }), + ), + onSuccess: (size) => Ref.set(currentSize, size).pipe(Effect.andThen(Effect.fail(cause))), + }), + ); + const pruneOverflowBackups = Effect.gen(function* () { - const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + const entries = yield* fileSystem.readDirectory(directory); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; - yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(path.join(directory, entry), { force: true }); } }); const rotate = Effect.gen(function* () { - yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }); for (let index = maxFiles - 1; index >= 1; index -= 1) { const source = withSuffix(index); - const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); + const sourceExists = yield* fileSystem.exists(source); if (sourceExists) { yield* fileSystem.rename(source, withSuffix(index + 1)); } } - const currentExists = yield* fileSystem - .exists(input.filePath) - .pipe(Effect.orElseSucceed(() => false)); + const currentExists = yield* fileSystem.exists(input.filePath); if (currentExists) { yield* fileSystem.rename(input.filePath, withSuffix(1)); } yield* Ref.set(currentSize, 0); - }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), - ); + }); - const writeBytes = (chunk: Uint8Array): Effect.Effect => { + const writeBytes = ( + chunk: Uint8Array, + ): Effect.Effect => { if (chunk.byteLength === 0) return Effect.void; return mutex.withPermits(1)( @@ -178,11 +247,9 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio yield* rotate; } }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), + Effect.catchTags({ + PlatformError: recoverCurrentSize, + }), ), ); }; @@ -190,6 +257,7 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio yield* pruneOverflowBackups; return { + filePath: input.filePath, writeBytes, writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), } satisfies RotatingLogFileWriter; @@ -199,10 +267,17 @@ const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, ): Effect.Effect => - Effect.sync(() => { - const output = streamName === "stderr" ? process.stderr : process.stdout; - output.write(chunk); - }).pipe(Effect.ignore); + Effect.try({ + try: () => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }, + catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), + }).pipe( + Effect.catchTags({ + DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( function* ( @@ -222,17 +297,47 @@ const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackend annotations: input.annotations, spans: {}, fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, - }); - yield* logFile.writeText(`${encoded}\n`); - }).pipe(Effect.ignore({ log: true })); + }).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "encode-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + yield* logFile.writeText(`${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "write-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + DesktopBackendOutputLogWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); }, ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const logFilePath = environment.path.join(environment.logDir, "server-child.log"); const writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "server-child.log"), - }).pipe(Effect.option); + filePath: logFilePath, + }).pipe( + Effect.mapError((cause) => new DesktopBackendOutputLogSetupError({ logFilePath, cause })), + Effect.map(Option.some), + Effect.catchTags({ + DesktopBackendOutputLogSetupError: (error) => + Effect.logError(error.message, { error }).pipe(Effect.as(Option.none())), + }), + ); const service = Option.match(writer, { onNone: () => DesktopBackendOutputLogNoop, From d1339f38462c206d65d3c8d8396c1ddfdfbb807c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:45 -0700 Subject: [PATCH 21/80] [codex] Structure client state key errors (#3374) Co-authored-by: codex --- .../client-runtime/src/state/assets.test.ts | 28 ++++++++- packages/client-runtime/src/state/assets.ts | 32 +++++++++- .../client-runtime/src/state/entities.test.ts | 58 +++++++++++++++++++ packages/client-runtime/src/state/entities.ts | 50 +++++++++++++++- packages/client-runtime/src/state/threads.ts | 25 +------- 5 files changed, 164 insertions(+), 29 deletions(-) diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 1a4cf384663d..58add31d6bbe 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -4,7 +4,33 @@ import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -import { createAssetEnvironmentAtoms } from "./assets.ts"; +import { + createAssetEnvironmentAtoms, + InvalidAssetCollectionKeyError, + parseAssetCollectionKey, +} from "./assets.ts"; + +describe("asset collection keys", () => { + it("preserves malformed JSON and its native cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseAssetCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidAssetCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.any(SyntaxError) }); + }); + + it("rejects invalid asset collection shapes", () => { + const key = JSON.stringify(["environment-1", [{ _tag: "unknown" }]]); + + expect(() => parseAssetCollectionKey(key)).toThrowError(InvalidAssetCollectionKeyError); + }); +}); describe("createAssetEnvironmentAtoms", () => { it("keys asset URL queries by environment and resource", () => { diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 6863de9055f9..e407f5d0028f 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,4 +1,5 @@ -import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts"; +import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -8,6 +9,32 @@ const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; const ASSET_URL_STALE_TIME_MS = 5 * 60_000; const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; +export class InvalidAssetCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidAssetCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeAssetCollectionKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]), +); + +export function parseAssetCollectionKey( + key: string, +): readonly [EnvironmentId, ReadonlyArray] { + try { + return decodeAssetCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidAssetCollectionKeyError({ key, cause }); + } +} + export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { try { return new URL(relativeUrl, httpBaseUrl).toString(); @@ -27,8 +54,7 @@ export function createAssetEnvironmentAtoms( refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, }); const createUrlsFamily = Atom.family((key: string) => { - const [rawEnvironmentId, resources] = JSON.parse(key) as [string, ReadonlyArray]; - const environmentId = EnvironmentId.make(rawEnvironmentId); + const [environmentId, resources] = parseAssetCollectionKey(key); return Atom.make((get) => resources.map((resource) => get( diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 2bdb8f842504..c772d134a677 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -11,6 +11,14 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { PrimaryConnectionTarget } from "../connection/model.ts"; +import { + InvalidScopedProjectKeyError, + InvalidScopedProjectRefCollectionKeyError, + InvalidScopedThreadKeyError, + parseProjectKey, + parseProjectRefCollectionKey, + parseThreadKey, +} from "./entities.ts"; import type { EnvironmentShellState } from "./shell.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; @@ -25,6 +33,56 @@ const OTHER_PROJECT_ID = ProjectId.make("project-2"); const THREAD_ID = ThreadId.make("thread-1"); const OTHER_THREAD_ID = ThreadId.make("thread-2"); +describe("scoped entity keys", () => { + it("preserves an invalid project key as structured error data", () => { + const key = "missing-project-key-separator"; + let error: unknown; + + try { + parseProjectKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedProjectKeyError({ key })); + }); + + it("preserves an invalid thread key as structured error data", () => { + const key = "missing-thread-key-separator"; + let error: unknown; + + try { + parseThreadKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedThreadKeyError({ key })); + }); + + it("preserves malformed project reference collection input and its cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseProjectRefCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.anything() }); + }); + + it("rejects invalid project reference collection shapes", () => { + const key = JSON.stringify([["environment-1"]]); + + expect(() => parseProjectRefCollectionKey(key)).toThrowError( + InvalidScopedProjectRefCollectionKeyError, + ); + }); +}); + const THREAD_SHELL = { id: THREAD_ID, projectId: PROJECT_ID, diff --git a/packages/client-runtime/src/state/entities.ts b/packages/client-runtime/src/state/entities.ts index 4bcf16f7cfd0..e90f31d6da40 100644 --- a/packages/client-runtime/src/state/entities.ts +++ b/packages/client-runtime/src/state/entities.ts @@ -5,6 +5,45 @@ import { type ScopedProjectRef, type ScopedThreadRef, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class InvalidScopedProjectKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped project atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedThreadKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedThreadKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped thread atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedProjectRefCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectRefCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid scoped project reference collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeProjectRefCollectionKey = Schema.decodeUnknownSync( + Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +); export function projectKey(ref: ScopedProjectRef): string { return `${ref.environmentId}\u0000${ref.projectId}`; @@ -21,7 +60,7 @@ export function projectRefCollectionKey(refs: ReadonlyArray): export function parseProjectKey(key: string): ScopedProjectRef { const separator = key.indexOf("\u0000"); if (separator < 0) { - throw new Error("Invalid scoped project atom key."); + throw new InvalidScopedProjectKeyError({ key }); } return { environmentId: EnvironmentId.make(key.slice(0, separator)), @@ -30,7 +69,12 @@ export function parseProjectKey(key: string): ScopedProjectRef { } export function parseProjectRefCollectionKey(key: string): ReadonlyArray { - const entries = JSON.parse(key) as ReadonlyArray; + let entries: ReadonlyArray; + try { + entries = decodeProjectRefCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidScopedProjectRefCollectionKeyError({ key, cause }); + } return entries.map(([environmentId, projectId]) => ({ environmentId: EnvironmentId.make(environmentId), projectId: ProjectId.make(projectId), @@ -40,7 +84,7 @@ export function parseProjectRefCollectionKey(key: string): ReadonlyArray( runtime: Atom.AtomRuntime, ) { const family = Atom.family((key: string) => { - const { environmentId, threadId } = parseThreadAtomKey(key); + const { environmentId, threadId } = parseThreadKey(key); return runtime .atom(threadStateChanges(environmentId, threadId), { initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, @@ -262,7 +243,7 @@ export function createEnvironmentThreadStateAtoms( return { stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => - family(threadAtomKey(environmentId, threadId)), + family(threadKey({ environmentId, threadId })), }; } From 50767683b396cf7065bb18072d6d14c05475b6e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:50 -0700 Subject: [PATCH 22/80] [codex] Preserve terminal preview link failure context (#3367) Co-authored-by: codex --- .../preview/openTerminalLinkInPreview.test.ts | 130 ++++++++++++++++++ .../preview/openTerminalLinkInPreview.ts | 50 ++++++- 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/preview/openTerminalLinkInPreview.test.ts diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts new file mode 100644 index 000000000000..47f03761f6bb --- /dev/null +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -0,0 +1,130 @@ +import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + openTerminalLinkInPreview, + TerminalLinkContextMenuShowError, + TerminalLinkPreviewOpenError, +} from "./openTerminalLinkInPreview"; + +vi.mock("~/previewStateStore", () => ({ + applyPreviewServerSnapshot: vi.fn(), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openBrowser: vi.fn() }), + }, +})); + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-20T00:00:00.000Z", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("openTerminalLinkInPreview", () => { + it("preserves context-menu failures with terminal link context before falling back", async () => { + const cause = new Error("menu unavailable"); + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:3000/path?token=secret", + position: { x: 12, y: 34 }, + threadRef, + openPreview, + localApi: { + contextMenu: { + show: vi.fn(async () => { + throw cause; + }), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://localhost:3000", + cause, + }); + expect(error.message).not.toContain("menu unavailable"); + expect(error.targetOrigin).not.toContain("secret"); + }); + + it("preserves the complete preview failure cause before falling back", async () => { + const rpcError = new Error("preview unavailable"); + const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://127.0.0.1:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(cause), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledOnce(); + const error = reportError.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError); + expect(error).toMatchObject({ + environmentId: "local", + threadId: "thread-1", + targetOrigin: "http://127.0.0.1:5173", + cause, + }); + expect(error.message).not.toContain("preview unavailable"); + }); + + it("does not report or fall back when opening the preview is interrupted", async () => { + const fallbackToBrowser = vi.fn(); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await openTerminalLinkInPreview({ + url: "http://localhost:5173/", + position: { x: 12, y: 34 }, + threadRef, + openPreview: async () => AsyncResult.failure(Cause.interrupt()), + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser, + }); + + expect(reportError).not.toHaveBeenCalled(); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 216cce060e2f..312eab9eb357 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,10 +1,37 @@ import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { isPreviewableUrl } from "@t3tools/shared/preview"; +import * as Schema from "effect/Schema"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +const terminalLinkErrorContext = { + environmentId: Schema.String, + threadId: Schema.String, + targetOrigin: Schema.String, + cause: Schema.Defect(), +}; + +export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass()( + "TerminalLinkContextMenuShowError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; + } +} + +export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass()( + "TerminalLinkPreviewOpenError", + terminalLinkErrorContext, +) { + override get message(): string { + return `Failed to open terminal link ${this.targetOrigin} in preview for thread ${this.threadId}.`; + } +} + interface OpenTerminalLinkInPreviewInput { readonly url: string; readonly position: { x: number; y: number }; @@ -27,6 +54,12 @@ export async function openTerminalLinkInPreview( return; } + const errorContext = { + environmentId: input.threadRef.environmentId, + threadId: input.threadRef.threadId, + targetOrigin: new URL(input.url).origin, + }; + let choice: "open-in-preview" | "open-in-browser" | null; try { choice = await input.localApi.contextMenu.show( @@ -36,7 +69,13 @@ export async function openTerminalLinkInPreview( ], input.position, ); - } catch { + } catch (cause) { + console.error( + new TerminalLinkContextMenuShowError({ + ...errorContext, + cause, + }), + ); input.fallbackToBrowser(); return; } @@ -47,6 +86,15 @@ export async function openTerminalLinkInPreview( input: { threadId: input.threadRef.threadId, url: input.url }, }); if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } + console.error( + new TerminalLinkPreviewOpenError({ + ...errorContext, + cause: result.cause, + }), + ); input.fallbackToBrowser(); return; } From 58053d146b1ea7201884867d1543ccbbc4c05ffd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:54 -0700 Subject: [PATCH 23/80] [codex] Structure terminal PTY operation failures (#3364) Co-authored-by: codex --- apps/server/src/terminal/Manager.test.ts | 101 ++++++++++++++++++- apps/server/src/terminal/Manager.ts | 122 ++++++++++++++++------- packages/contracts/src/terminal.ts | 85 ++++++++++++---- 3 files changed, 251 insertions(+), 57 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c4c73ea74896..3a1cabc4a270 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -39,6 +39,8 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; readonly pid: number; + writeFailure: unknown | undefined; + resizeFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; @@ -48,10 +50,16 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } write(data: string): void { + if (this.writeFailure !== undefined) { + throw this.writeFailure; + } this.writes.push(data); } resize(cols: number, rows: number): void { + if (this.resizeFailure !== undefined) { + throw this.resizeFailure; + } this.resizeCalls.push({ cols, rows }); } @@ -435,6 +443,39 @@ it.layer( fs.writeFileString(filePath, contents), ); + it.effect("reports a missing cwd without an artificial cause", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "missing-cwd"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotFoundError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("reports a cwd that is not a directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "cwd-file"); + yield* writeFileString(cwd, "not a directory"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotDirectoryError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; @@ -452,9 +493,11 @@ it.layer( ); expect(error).toMatchObject({ - _tag: "TerminalCwdError", + _tag: "TerminalCwdStatError", cwd: blockedCwd, - reason: "statFailed", + cause: { + _tag: "PlatformError", + }, }); }), ); @@ -498,6 +541,60 @@ it.layer( }), ); + it.effect("preserves structured context and causes for PTY I/O failures", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const writeCause = new Error("PTY input handle is unavailable"); + process.writeFailure = writeCause; + const writeError = yield* Effect.flip( + manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "secret input that must not be attached to the error", + }), + ); + + expect(writeError).toMatchObject({ + _tag: "TerminalWriteError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + }); + expect(writeError.cause).toBe(writeCause); + expect(writeError).not.toHaveProperty("data"); + + const resizeCause = new Error("PTY resize handle is unavailable"); + process.resizeFailure = resizeCause; + const resizeError = yield* Effect.flip( + manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 132, + rows: 40, + }), + ); + + expect(resizeError).toMatchObject({ + _tag: "TerminalResizeError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + cols: 132, + rows: 40, + }); + expect(resizeError.cause).toBe(resizeCause); + + process.resizeFailure = undefined; + yield* manager.open(openInput({ cols: 132, rows: 40 })); + expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]); + }), + ); + it.effect("ignores delayed resize requests after a terminal closes", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9fa9d07ebc97..6347fdfc64d6 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -9,10 +9,15 @@ import { DEFAULT_TERMINAL_ID, TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalResizeError, TerminalSessionLookupError, + TerminalWriteError, type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalClearInput, @@ -58,10 +63,15 @@ import * as PtyAdapter from "./PtyAdapter.ts"; export { TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalResizeError, TerminalSessionLookupError, + TerminalWriteError, }; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; @@ -190,6 +200,25 @@ interface TerminalSubprocessInspector { ): Effect.Effect; } +const resizePtyProcess = ( + session: TerminalSessionState, + process: PtyAdapter.PtyProcess, + cols: number, + rows: number, +) => + Effect.try({ + try: () => process.resize(cols, rows), + catch: (cause) => + new TerminalResizeError({ + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid: process.pid, + cols, + rows, + cause, + }), + }); + export interface ShellCandidate { shell: string; args?: string[]; @@ -1157,16 +1186,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const legacyHistoryPath = (threadId: string) => path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); - const toTerminalHistoryError = - (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => - (cause: unknown) => - new TerminalHistoryError({ - operation, - threadId, - terminalId, - cause, - }); - const readManagerState = SynchronizedRef.get(managerStateRef); const modifyManagerState = ( @@ -1250,7 +1269,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId, terminalId, signal: "SIGTERM", - error: error.message, + cause: error, }).pipe(Effect.as(false)), ), ); @@ -1274,7 +1293,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId, terminalId, signal: "SIGKILL", - error: error.message, + cause: error, }), ), ); @@ -1372,16 +1391,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if ( yield* fileSystem .exists(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ) ) { const raw = yield* fileSystem .readFileString(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ); const capped = capHistory(raw, historyLineLimit); if (capped !== raw) { yield* fileSystem .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), + ), + ); } return capped; } @@ -1394,18 +1426,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if ( !(yield* fileSystem .exists(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + )) ) { return ""; } const raw = yield* fileSystem .readFileString(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); const capped = capHistory(raw, historyLineLimit); yield* fileSystem .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); yield* fileSystem.remove(legacyPath, { force: true }).pipe( Effect.catch((cleanupError) => Effect.logWarning("failed to remove legacy terminal history", { @@ -1472,20 +1519,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { const stats = yield* fileSystem.stat(cwd).pipe( - Effect.mapError( - (cause) => - new TerminalCwdError({ - cwd, - reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", - cause, - }), - ), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? new TerminalCwdNotFoundError({ cwd }) + : new TerminalCwdStatError({ cwd, cause }), + }), ); if (stats.type !== "Directory") { - return yield* new TerminalCwdError({ - cwd, - reason: "notDirectory", - }); + return yield* new TerminalCwdNotDirectoryError({ cwd }); } }); @@ -1881,7 +1923,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* Effect.logError("failed to start terminal", { threadId: session.threadId, terminalId: session.terminalId, - error: message, + cause: error, ...(startedShell ? { shell: startedShell } : {}), }); } @@ -2168,10 +2210,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { + yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows); liveSession.cols = targetCols; liveSession.rows = targetRows; liveSession.updatedAt = yield* nowIso; - liveSession.process.resize(targetCols, targetRows); } return snapshot(liveSession); @@ -2219,10 +2261,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.status === "running" && (session.cols !== targetCols || session.rows !== targetRows) ) { + const process = session.process; + yield* resizePtyProcess(session, process, targetCols, targetRows); session.cols = targetCols; session.rows = targetRows; session.updatedAt = yield* nowIso; - yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); } return snapshot(session); @@ -2409,7 +2452,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } - yield* Effect.sync(() => process.write(input.data)); + yield* Effect.try({ + try: () => process.write(input.data), + catch: (cause) => + new TerminalWriteError({ + threadId: input.threadId, + terminalId, + terminalPid: process.pid, + cause, + }), + }); }); const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { @@ -2422,10 +2474,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (!process || session.value.status !== "running") { return; } + yield* resizePtyProcess(session.value, process, input.cols, input.rows); session.value.cols = input.cols; session.value.rows = input.rows; session.value.updatedAt = yield* nowIso; - yield* Effect.sync(() => process.resize(input.cols, input.rows)); }); const resize: TerminalManager["Service"]["resize"] = (input) => diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index a3c8e37e7f92..fa5f18211695 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -232,34 +232,47 @@ export const TerminalAttachStreamEvent = Schema.Union([ ]); export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type; -export class TerminalCwdError extends Schema.TaggedErrorClass()( - "TerminalCwdError", +export class TerminalCwdNotFoundError extends Schema.TaggedErrorClass()( + "TerminalCwdNotFoundError", + { + cwd: Schema.String, + }, +) { + override get message() { + return `Terminal cwd does not exist: ${this.cwd}`; + } +} + +export class TerminalCwdNotDirectoryError extends Schema.TaggedErrorClass()( + "TerminalCwdNotDirectoryError", { cwd: Schema.String, - reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), - cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - if (this.reason === "notDirectory") { - return `Terminal cwd is not a directory: ${this.cwd}`; - } - if (this.reason === "notFound") { - return `Terminal cwd does not exist: ${this.cwd}`; - } - const causeMessage = - this.cause !== undefined && - this.cause !== null && - typeof this.cause === "object" && - "message" in this.cause - ? this.cause.message - : undefined; - return typeof causeMessage === "string" && causeMessage.length > 0 - ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})` - : `Failed to access terminal cwd: ${this.cwd}`; + return `Terminal cwd is not a directory: ${this.cwd}`; } } +export class TerminalCwdStatError extends Schema.TaggedErrorClass()( + "TerminalCwdStatError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to access terminal cwd: ${this.cwd}`; + } +} + +export const TerminalCwdError = Schema.Union([ + TerminalCwdNotFoundError, + TerminalCwdNotDirectoryError, + TerminalCwdStatError, +]); +export type TerminalCwdError = typeof TerminalCwdError.Type; + export class TerminalHistoryError extends Schema.TaggedErrorClass()( "TerminalHistoryError", { @@ -298,10 +311,42 @@ export class TerminalNotRunningError extends Schema.TaggedErrorClass()( + "TerminalWriteError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to write to terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid}`; + } +} + +export class TerminalResizeError extends Schema.TaggedErrorClass()( + "TerminalResizeError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cols: TerminalColsSchema, + rows: TerminalRowsSchema, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to resize terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid} to ${this.cols}x${this.rows}`; + } +} + export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, TerminalNotRunningError, + TerminalWriteError, + TerminalResizeError, ]); export type TerminalError = typeof TerminalError.Type; From b88873508e8dedbff772b9835604d66c8c72c17c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:12:59 -0700 Subject: [PATCH 24/80] [codex] Structure cross-client clipboard failures (#3361) Co-authored-by: codex --- apps/mobile/src/app/settings/environments.tsx | 4 +- .../connection/ConnectionEnvironmentRow.tsx | 2 +- .../EnvironmentConnectionNotice.tsx | 6 +- .../src/features/threads/ThreadFeed.tsx | 8 ++- .../mobile/src/lib/copyTextWithHaptic.test.ts | 60 +++++++++++++++- apps/mobile/src/lib/copyTextWithHaptic.ts | 69 +++++++++++++++++- apps/web/src/components/PlanSidebar.tsx | 2 +- .../src/components/chat/ProposedPlanCard.tsx | 1 + .../settings/DiagnosticsSettings.tsx | 17 ++--- apps/web/src/components/ui/toast.tsx | 2 +- apps/web/src/hooks/useCopyToClipboard.test.ts | 58 +++++++++++++++ apps/web/src/hooks/useCopyToClipboard.ts | 71 +++++++++++++++---- 12 files changed, 261 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/hooks/useCopyToClipboard.test.ts diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index c09bb3cebe64..8f65c630a54e 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -397,7 +397,7 @@ function CloudEnvironmentRowShell(props: { className={cn("text-xs leading-[16px] underline", statusClassName)} onLongPress={(event) => { event.stopPropagation(); - copyTextWithHaptic(errorTraceId); + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); }} onPress={(event) => { event.stopPropagation(); @@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) { { - copyTextWithHaptic(props.traceId); + copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); }} className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" > diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index f5aa26be9600..7b901ec4c660 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -101,7 +101,7 @@ export function ConnectionEnvironmentRow(props: { className="underline" onLongPress={(event) => { event.stopPropagation(); - copyTextWithHaptic(statusTraceId); + copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); }} onPress={(event) => { event.stopPropagation(); diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index 9b8c96d25ead..373b0d3ef035 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: { accessibilityHint="Copies the trace ID" accessibilityRole="button" className="underline decoration-dotted" - onPress={() => copyTextWithHaptic(props.connection.traceId!)} + onPress={() => + copyTextWithHaptic(props.connection.traceId!, { + target: "connection-trace-id", + }) + } > {props.connection.traceId} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7424d8563688..4f8ca9c747de 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,4 +1,3 @@ -import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; @@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { - void Clipboard.setStringAsync(value); - void Haptics.selectionAsync(); + copyTextWithHaptic(value, { + target: "thread-work-row", + feedback: "selection", + }); setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index d15a3a1a59b4..236fb44cd6b0 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ impactAsync: vi.fn(), + selectionAsync: vi.fn(), setStringAsync: vi.fn(), })); @@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({ Light: "light", }, impactAsync: mocks.impactAsync, + selectionAsync: mocks.selectionAsync, })); -import { copyTextWithHaptic } from "./copyTextWithHaptic"; +import { + CopyTextClipboardWriteError, + CopyTextHapticFeedbackError, + copyTextWithHaptic, +} from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { beforeEach(() => { vi.clearAllMocks(); mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); mocks.impactAsync.mockResolvedValue(undefined); + mocks.selectionAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("triggers haptic feedback without waiting for the clipboard promise", () => { @@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => { expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); expect(mocks.impactAsync).toHaveBeenCalledWith("light"); }); + + it("preserves selection feedback for thread work rows", () => { + copyTextWithHaptic("work output", { + target: "thread-work-row", + feedback: "selection", + }); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); + expect(mocks.selectionAsync).toHaveBeenCalledOnce(); + expect(mocks.impactAsync).not.toHaveBeenCalled(); + }); + + it("reports structured failures without including clipboard contents", async () => { + const clipboardCause = new Error("native clipboard failure"); + const hapticCause = new Error("native haptic failure"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); + mocks.impactAsync.mockRejectedValueOnce(hapticCause); + + copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); + + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledTimes(2); + }); + + const failures = consoleError.mock.calls.map(([failure]) => failure); + const clipboardError = failures.find( + (failure) => failure instanceof CopyTextClipboardWriteError, + ); + expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError); + expect(clipboardError).toMatchObject({ + target: "connection-trace-id", + cause: clipboardCause, + }); + expect((clipboardError as Error).message).not.toContain("secret clipboard contents"); + + const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError); + expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError); + expect(hapticError).toMatchObject({ + target: "connection-trace-id", + feedback: "light-impact", + cause: hapticCause, + }); + expect((hapticError as Error).message).not.toContain("secret clipboard contents"); + }); }); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 80f725f5b006..1cc8c94eef7a 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -1,7 +1,70 @@ +import * as Schema from "effect/Schema"; import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; -export function copyTextWithHaptic(value: string): void { - void Clipboard.setStringAsync(value); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +export class CopyTextClipboardWriteError extends Schema.TaggedErrorClass()( + "CopyTextClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass()( + "CopyTextHapticFeedbackError", + { + target: Schema.String, + feedback: Schema.Literals(["light-impact", "selection"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to trigger ${this.feedback} haptic feedback after copying ${this.target}.`; + } +} + +export function copyTextWithHaptic( + value: string, + options: { + readonly target?: string; + readonly feedback?: "light-impact" | "selection"; + } = {}, +): void { + const target = options.target ?? "text"; + const feedback = options.feedback ?? "light-impact"; + + void (async () => { + try { + await Clipboard.setStringAsync(value); + } catch (cause) { + console.error( + new CopyTextClipboardWriteError({ + target, + cause, + }), + ); + } + })(); + + void (async () => { + try { + if (feedback === "selection") { + await Haptics.selectionAsync(); + } else { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + } catch (cause) { + console.error( + new CopyTextHapticFeedbackError({ + target, + feedback, + cause, + }), + ); + } + })(); } diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index fec255355a5a..abc0db79b6cb 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({ const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { reportFailure: false, }); - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index e507a2f77091..9746857a8cac 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ reportFailure: false, }); const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "plan", onError: (error) => { toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 6df3367c6429..92d8d3f68272 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -32,6 +32,7 @@ import { } from "../../state/server"; import { shellEnvironment } from "../../state/shell"; import { usePrimaryEnvironment } from "../../state/environments"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -246,16 +247,10 @@ function DiagnosticsTable({ } function TraceIdCell({ traceId }: { traceId: string }) { - const [copied, setCopied] = useState(false); - const copyTraceId = useCallback(() => { - void navigator.clipboard - ?.writeText(traceId) - .then(() => { - setCopied(true); - window.setTimeout(() => setCopied(false), 1_200); - }) - .catch(() => undefined); - }, [traceId]); + const { copyToClipboard, isCopied: copied } = useCopyToClipboard({ + target: "trace ID", + timeout: 1_200, + }); return (
@@ -281,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) { type="button" className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" aria-label={copied ? "Copied trace ID" : "Copy trace ID"} - onClick={copyTraceId} + onClick={() => copyToClipboard(traceId)} > diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 2c2a554871a4..d48cda453b89 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -117,7 +117,7 @@ function handleToastDismissClick( } function CopyErrorButton({ text }: { text: string }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" }); const label = isCopied ? "Copied error" : "Copy error"; return ( diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts new file mode 100644 index 000000000000..ccac333cd488 --- /dev/null +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + ClipboardApiUnavailableError, + ClipboardWriteError, + writeTextToClipboard, +} from "./useCopyToClipboard"; + +describe("writeTextToClipboard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reports unavailable clipboard support with structural context", async () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + + const error = await writeTextToClipboard("plan contents", "plan").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(ClipboardApiUnavailableError); + expect(error).toMatchObject({ + target: "plan", + }); + expect((error as Error).message).not.toContain("plan contents"); + }); + + it("preserves the exact clipboard failure without exposing copied contents", async () => { + const cause = new Error("browser clipboard failure"); + const writeText = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + const error = await writeTextToClipboard("secret clipboard contents", "error-message").then( + () => undefined, + (failure: unknown) => failure, + ); + + expect(writeText).toHaveBeenCalledWith("secret clipboard contents"); + expect(error).toBeInstanceOf(ClipboardWriteError); + expect(error).toMatchObject({ + target: "error-message", + cause, + }); + expect((error as Error).message).not.toContain("secret clipboard contents"); + }); + + it("keeps empty values as a no-op when clipboard support is available", async () => { + const writeText = vi.fn(); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index d1feb621159f..0129f2d6593d 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -1,11 +1,61 @@ import * as React from "react"; +import * as Schema from "effect/Schema"; + +export class ClipboardApiUnavailableError extends Schema.TaggedErrorClass()( + "ClipboardApiUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while copying ${this.target}.`; + } +} + +export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export async function writeTextToClipboard(value: string, target = "text") { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.writeText + ) { + throw new ClipboardApiUnavailableError({ + target, + }); + } + + if (!value) return false; + + try { + await navigator.clipboard.writeText(value); + return true; + } catch (cause) { + throw new ClipboardWriteError({ + target, + cause, + }); + } +} export function useCopyToClipboard({ timeout = 2000, + target = "text", onCopy, onError, }: { timeout?: number; + target?: string; onCopy?: (ctx: TContext) => void; onError?: (error: Error, ctx: TContext) => void; } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { @@ -13,22 +63,18 @@ export function useCopyToClipboard({ const timeoutIdRef = React.useRef(null); const onCopyRef = React.useRef(onCopy); const onErrorRef = React.useRef(onError); + const targetRef = React.useRef(target); const timeoutRef = React.useRef(timeout); onCopyRef.current = onCopy; onErrorRef.current = onError; + targetRef.current = target; timeoutRef.current = timeout; const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); - return; - } - - if (!value) return; - - navigator.clipboard.writeText(value).then( - () => { + void writeTextToClipboard(value, targetRef.current).then( + (didCopy) => { + if (!didCopy) return; if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); } @@ -44,11 +90,8 @@ export function useCopyToClipboard({ } }, (error) => { - if (onErrorRef.current) { - onErrorRef.current(error, ctx); - } else { - console.error(error); - } + console.error(error); + onErrorRef.current?.(error, ctx); }, ); }, []); From d7718e81e0af33d0a2d0f515385ac210f352fffb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:13:04 -0700 Subject: [PATCH 25/80] [codex] Structure mobile notification navigation failures (#3359) Co-authored-by: codex --- .../notificationNavigation.test.ts | 75 ++++++++++++++++++- .../agent-awareness/notificationNavigation.ts | 15 ++-- .../notificationResponseConsumer.ts | 58 ++++++++++++++ 3 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 6d7c247dfad5..2dd3ca03de29 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { NotificationResponse } from "expo-notifications"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; import { extractAgentNotificationDeepLink, @@ -18,6 +21,76 @@ function responseWithData(data: Record, identifier = "notificat }; } +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("consumeLastAgentNotificationResponse", () => { + it("reports which initial-response operation failed", async () => { + const cause = new Error("notification lookup unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.reject(cause), + clearLastResponse: () => Promise.resolve(), + handleResponse: vi.fn(), + }); + + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "read", + }), + ); + }); + + it("routes a response before reporting a clear failure", async () => { + const cause = new Error("notification clear unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-clear") as NotificationResponse; + const handleResponse = vi.fn(); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse: () => Promise.reject(cause), + handleResponse, + }); + + expect(handleResponse).toHaveBeenCalledWith(response); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "clear", + notificationId: "notification-clear", + }), + ); + }); + + it("reports routing failures before clearing the response", async () => { + const cause = new Error("notification routing unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-route") as NotificationResponse; + const clearLastResponse = vi.fn(() => Promise.resolve()); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: () => { + throw cause; + }, + }); + + expect(clearLastResponse).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "route", + notificationId: "notification-route", + }), + ); + }); +}); + describe("extractAgentNotificationDeepLink", () => { it("uses explicit deep links from APNs payload data", () => { expect( diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index a70276236533..18bb93d723ee 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -3,6 +3,7 @@ import * as Notifications from "expo-notifications"; import { useRouter } from "expo-router"; import { routeAgentNotificationResponseOnce } from "./notificationPayload"; +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; export function useAgentNotificationNavigation(): void { const router = useRouter(); @@ -18,15 +19,11 @@ export function useAgentNotificationNavigation(): void { }; const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); - void Notifications.getLastNotificationResponseAsync() - .then((response) => { - if (response) { - handleResponse(response); - return Notifications.clearLastNotificationResponseAsync(); - } - return undefined; - }) - .catch(() => undefined); + void consumeLastAgentNotificationResponse({ + getLastResponse: () => Notifications.getLastNotificationResponseAsync(), + clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), + handleResponse, + }); return () => { subscription.remove(); diff --git a/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts new file mode 100644 index 000000000000..be6bfa820fa3 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts @@ -0,0 +1,58 @@ +import type { NotificationResponse } from "expo-notifications"; +import * as Schema from "effect/Schema"; + +export class NotificationNavigationError extends Schema.TaggedErrorClass()( + "NotificationNavigationError", + { + operation: Schema.Literals(["read", "route", "clear"]), + notificationId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} the last notification response.`; + } +} + +export async function consumeLastAgentNotificationResponse(input: { + readonly getLastResponse: () => Promise; + readonly clearLastResponse: () => Promise; + readonly handleResponse: (response: NotificationResponse) => void; +}): Promise { + let response: NotificationResponse | null; + try { + response = await input.getLastResponse(); + } catch (cause) { + console.error(new NotificationNavigationError({ operation: "read", cause })); + return; + } + + if (!response) { + return; + } + + try { + input.handleResponse(response); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "route", + notificationId: response.notification.request.identifier, + cause, + }), + ); + return; + } + + try { + await input.clearLastResponse(); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "clear", + notificationId: response.notification.request.identifier, + cause, + }), + ); + } +} From 97de7d7e1535ddf485ea8806c639f27e99cfbcb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:13:48 -0700 Subject: [PATCH 26/80] [codex] Structure agent awareness registration errors (#3328) Co-authored-by: codex --- .../remoteRegistration.test.ts | 23 ++++ .../agent-awareness/remoteRegistration.ts | 107 ++++++++++++++---- 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 43d62b81622a..7f97d7c718cb 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -18,6 +18,7 @@ import { cryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { + AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, @@ -267,6 +268,28 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }); + it.effect("preserves Live Activity push-token lookup failures", () => { + const cause = new Error("native token lookup failed"); + const activity = { + getPushToken: vi.fn(() => Promise.reject(cause)), + addPushTokenListener: vi.fn(), + }; + + return Effect.gen(function* () { + const error = yield* Effect.flip( + registerLiveActivityPushToken({ activity: activity as never }), + ); + + expect(error).toBeInstanceOf(AgentAwarenessOperationError); + expect(error).toMatchObject({ + _tag: "AgentAwarenessOperationError", + operation: "read-live-activity-push-token", + cause, + message: "Agent awareness operation read-live-activity-push-token failed.", + }); + }).pipe(Effect.provide(relayTestLayer)); + }); + it.effect( "reports Live Activity token registration as skipped when relay auth is unavailable", () => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 98e38c740559..3281381e0e1d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -2,6 +2,7 @@ import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -28,6 +29,33 @@ import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; + +const AgentAwarenessOperation = Schema.Literals([ + "read-notification-permissions", + "read-native-push-token", + "read-device-registration-relay-token", + "read-device-unregistration-relay-token", + "read-live-activity-registration-relay-token", + "load-device-registration-identifier", + "load-device-registration-preferences", + "load-device-unregistration-identifier", + "read-live-activity-push-token", + "load-live-activity-registration-identifier", + "list-active-live-activities", +]); + +export class AgentAwarenessOperationError extends Schema.TaggedErrorClass()( + "AgentAwarenessOperationError", + { + operation: AgentAwarenessOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Agent awareness operation ${this.operation} failed.`; + } +} + const environmentConnections = new Map(); const activityPushTokenListeners = new WeakSet>(); let pushToStartSubscription: { remove: () => void } | null = null; @@ -137,14 +165,22 @@ function nativePushTokenRegistration(observedPushToken?: string) { } const permissions = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-notification-permissions", + cause, + }), }); if (!permissions.granted) { return { notificationsEnabled: false, pushToken: null }; } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-native-push-token", + cause, + }), }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -161,16 +197,19 @@ function nativePushTokenRegistration(observedPushToken?: string) { }); } -const relayToken = Effect.gen(function* () { - const provider = relayTokenProvider; - if (!provider) { - return null; - } - return yield* Effect.tryPromise({ - try: provider, - catch: (error) => error, +const relayToken = ( + operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token", +) => + Effect.gen(function* () { + const provider = relayTokenProvider; + if (!provider) { + return null; + } + return yield* Effect.tryPromise({ + try: provider, + catch: (cause) => new AgentAwarenessOperationError({ operation, cause }), + }); }); -}); function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, @@ -185,7 +224,7 @@ function registerDeviceWithRelay( return; } if (!readRelayConfig()) return; - const token = yield* relayToken; + const token = yield* relayToken("read-device-registration-relay-token"); if (expectedGeneration !== deviceRegistrationGeneration) { logRegistrationDebug("device registration cancelled after auth lookup", { expectedGeneration, @@ -220,7 +259,11 @@ function unregisterDeviceWithRelay(input: { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ try: input.tokenProvider, - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-device-unregistration-relay-token", + cause, + }), }); if (!token) { logRegistrationDebug("relay device unregistration skipped; user is not signed in"); @@ -240,7 +283,7 @@ function registerLiveActivityWithRelay( ): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return false; - const token = yield* relayToken; + const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { logRegistrationDebug("relay live activity registration skipped; user is not signed in"); return false; @@ -381,11 +424,19 @@ function registerDevice( const [deviceId, preferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-identifier", + cause, + }), }), Effect.tryPromise({ try: () => loadPreferences(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-preferences", + cause, + }), }), ]); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); @@ -519,7 +570,11 @@ export function unregisterAgentAwarenessDeviceForCurrentUser( return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-unregistration-identifier", + cause, + }), }); if (!deviceId) { return; @@ -544,7 +599,11 @@ export function registerLiveActivityPushToken(input: { const activityPushToken = yield* Effect.tryPromise({ try: () => input.activity.getPushToken(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-live-activity-push-token", + cause, + }), }); if (!activityPushToken) { if (activityPushTokenListeners.has(input.activity)) { @@ -592,7 +651,11 @@ function registerLiveActivityPushTokenValue(input: { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-registration-identifier", + cause, + }), }); const registered = yield* registerLiveActivityWithRelay({ deviceId, @@ -633,7 +696,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< const activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "list-active-live-activities", + cause, + }), }).pipe( Effect.catch((error) => Effect.sync(() => { From a51691a9b0a98e65f0e9b2b775b22a2382595e87 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:13:53 -0700 Subject: [PATCH 27/80] [codex] Structure server environment-label probe failures (#3321) Co-authored-by: codex --- .../ServerEnvironmentLabel.test.ts | 146 +++++++++++++++--- .../src/environment/ServerEnvironmentLabel.ts | 130 +++++++++++++--- 2 files changed, 235 insertions(+), 41 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts index 4bc9647fba5d..b5bb8a8ff1c4 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -2,12 +2,28 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; +import * as ServerEnvironmentLabel from "./ServerEnvironmentLabel.ts"; + +const isServerEnvironmentLabelFileError = Schema.is( + ServerEnvironmentLabel.ServerEnvironmentLabelFileError, +); +const isServerEnvironmentLabelCommandError = Schema.is( + ServerEnvironmentLabel.ServerEnvironmentLabelCommandError, +); + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} const runMock = vi.fn(); @@ -47,7 +63,7 @@ afterEach(() => { describe("resolveServerEnvironmentLabel", () => { it.effect("uses hostname fallback regardless of launch mode", () => Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); @@ -68,7 +84,7 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); @@ -85,7 +101,7 @@ describe("resolveServerEnvironmentLabel", () => { it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); @@ -107,7 +123,7 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); @@ -124,7 +140,7 @@ describe("resolveServerEnvironmentLabel", () => { it.effect("falls back to the hostname when friendly labels are unavailable", () => Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); @@ -132,25 +148,111 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - it.effect("falls back to the hostname when the friendly-label command is missing", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: "scutil", - argumentCount: 2, - cause: new Error("spawn scutil ENOENT"), - }), - ), - ); + it.effect("falls back to the hostname when the friendly-label command is missing", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const spawnCause = new Error("spawn scutil ENOENT"); + const processError = new ProcessRunner.ProcessSpawnError({ + command: "scutil", + argumentCount: 2, + cause: spawnCause, + }); + runMock.mockReturnValueOnce(Effect.fail(processError)); - const result = yield* resolveServerEnvironmentLabel({ + return Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); + }); expect(result).toBe("macbook-pro"); - }), - ); + expect(logs[0]?.message).toEqual([ + "Failed to run environment-label probe 'macos-computer-name' with scutil.", + ]); + const error = logs[0]?.annotations.cause; + expect(isServerEnvironmentLabelCommandError(error)).toBe(true); + if (isServerEnvironmentLabelCommandError(error)) { + expect(error.probe).toBe("macos-computer-name"); + expect(error.executable).toBe("scutil"); + expect(error.argumentCount).toBe(2); + expect(error).not.toHaveProperty("args"); + expect(error.message).not.toContain("--get"); + expect(error.message).not.toContain("ComputerName"); + expect(error.cause).toBe(processError); + expect(processError.cause).toBe(spawnCause); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + withHostPlatform(TestLayer, "darwin", "macbook-pro"), + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); + + it.effect("continues to hostnamectl after a machine-info inspect failure", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const fileCause = new Error("permission denied"); + const platformError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: "/etc/machine-info", + cause: fileCause, + }); + const fileSystemLayer = FileSystem.layerNoop({ + exists: () => Effect.fail(platformError), + }); + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: "CI Runner\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + return Effect.gen(function* () { + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }); + + expect(result).toBe("CI Runner"); + expect(logs[0]?.message).toEqual([ + "Failed to inspect environment-label file at /etc/machine-info.", + ]); + const error = logs[0]?.annotations.cause; + expect(isServerEnvironmentLabelFileError(error)).toBe(true); + if (isServerEnvironmentLabelFileError(error)) { + expect(error.operation).toBe("inspect"); + expect(error.path).toBe("/etc/machine-info"); + expect(error.cause).toBe(platformError); + expect(platformError.cause).toBe(fileCause); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + withHostPlatform(Layer.merge(ProcessRunnerTest, fileSystemLayer), "linux", "buildbox"), + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); it.effect("falls back to the cwd basename when the hostname is blank", () => Effect.gen(function* () { @@ -165,7 +267,7 @@ describe("resolveServerEnvironmentLabel", () => { }), ); - const result = yield* resolveServerEnvironmentLabel({ + const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ cwdBaseName: "t3code", }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts index 83c3b8bad8e6..bd034e0fa269 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -2,6 +2,7 @@ import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostPr import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as ProcessRunner from "../processRunner.ts"; @@ -9,6 +10,39 @@ interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; } +const ServerEnvironmentLabelCommandProbe = Schema.Literals([ + "macos-computer-name", + "linux-pretty-hostname", +]); +type ServerEnvironmentLabelCommandProbe = typeof ServerEnvironmentLabelCommandProbe.Type; + +export class ServerEnvironmentLabelFileError extends Schema.TaggedErrorClass()( + "ServerEnvironmentLabelFileError", + { + operation: Schema.Literals(["inspect", "read"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} environment-label file at ${this.path}.`; + } +} + +export class ServerEnvironmentLabelCommandError extends Schema.TaggedErrorClass()( + "ServerEnvironmentLabelCommandError", + { + probe: ServerEnvironmentLabelCommandProbe, + executable: Schema.String, + argumentCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to run environment-label probe '${this.probe}' with ${this.executable}.`; + } +} + function normalizeLabel(value: string | null | undefined): string | null { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : null; @@ -34,30 +68,80 @@ function parseMachineInfoValue(raw: string, key: string): string | null { const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fileSystem - .exists("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return null; - } - - return yield* fileSystem - .readFileString("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => null)); + const machineInfoPath = "/etc/machine-info"; + return yield* fileSystem.exists(machineInfoPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelFileError({ + operation: "inspect", + path: machineInfoPath, + cause, + }), + ), + Effect.flatMap((exists) => + exists + ? fileSystem.readFileString(machineInfoPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelFileError({ + operation: "read", + path: machineInfoPath, + cause, + }), + ), + ) + : Effect.succeed(null), + ), + Effect.catchTags({ + ServerEnvironmentLabelFileError: (error) => + Effect.logDebug(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + path: error.path, + cause: error, + }), + Effect.as(null), + ), + }), + ); }); -const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( - command: string, - args: readonly string[], -) { +const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (input: { + readonly probe: ServerEnvironmentLabelCommandProbe; + readonly command: string; + readonly args: readonly string[]; +}) { const processRunner = yield* ProcessRunner.ProcessRunner; const result = yield* processRunner .run({ - command, - args, + command: input.command, + args: input.args, timeoutBehavior: "timedOutResult", }) - .pipe(Effect.option); + .pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelCommandError({ + probe: input.probe, + executable: input.command, + argumentCount: input.args.length, + cause, + }), + ), + Effect.map(Option.some), + Effect.catchTags({ + ServerEnvironmentLabelCommandError: (error) => + Effect.logDebug(error.message).pipe( + Effect.annotateLogs({ + probe: error.probe, + executable: error.executable, + argumentCount: error.argumentCount, + cause: error, + }), + Effect.as(Option.none()), + ), + }), + ); if (Option.isNone(result) || result.value.code !== 0) { return null; @@ -69,7 +153,11 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { const platform = yield* HostProcessPlatform; if (platform === "darwin") { - return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); + return yield* runFriendlyLabelCommand({ + probe: "macos-computer-name", + command: "scutil", + args: ["--get", "ComputerName"], + }); } if (platform === "linux") { @@ -81,7 +169,11 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* } } - return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); + return yield* runFriendlyLabelCommand({ + probe: "linux-pretty-hostname", + command: "hostnamectl", + args: ["--pretty"], + }); } return null; From a76b7bbfb5ad1e87714d85516e68bda594f9f830 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:02 -0700 Subject: [PATCH 28/80] Preserve PortScanner probe defects (#3282) Co-authored-by: codex --- apps/server/src/preview/PortScanner.test.ts | 72 ++++++++++++++++++++- apps/server/src/preview/PortScanner.ts | 26 +++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 6c48f6d5c8bd..69b5729164da 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -3,14 +3,48 @@ import * as NodeNet from "node:net"; import { it as effectIt } from "@effect/vitest"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; -import { Effect, Layer } from "effect"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), + run: (input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cwd: input.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "PowerShell is not installed in the test environment", + }), + }), + ), }); + +const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => + PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { run }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "linux"), + ), + ), + ); + const TestPortDiscoveryLive = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), @@ -87,3 +121,37 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall }), ); }); + +effectIt("does not swallow process probe defects", () => + Effect.gen(function* () { + const defect = new Error("unexpected process probe defect"); + const layer = makeProbeFailureLayer(() => Effect.die(defect)); + + const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); + } + }), +); + +effectIt("does not swallow process probe interruption", () => + Effect.gen(function* () { + const layer = makeProbeFailureLayer(() => Effect.interrupt); + + const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 16ff0fed58fd..c306fca2b337 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -221,6 +221,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() { })); }); + const recoverProcessProbeFailure = + (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => + Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + cause: error, + probe, + platform: hostPlatform, + }).pipe(Effect.as(null)); + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map(); @@ -230,6 +238,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } } if (hostPlatform === "win32") { + const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner @@ -242,11 +251,18 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), - Effect.catchCause(() => Effect.succeed(null)), + Effect.catchTags({ + ProcessSpawnError: recoverWindowsProbeFailure, + ProcessStdinError: recoverWindowsProbeFailure, + ProcessOutputLimitError: recoverWindowsProbeFailure, + ProcessReadError: recoverWindowsProbeFailure, + ProcessTimeoutError: recoverWindowsProbeFailure, + }), ); if (listeners !== null) return listeners; return yield* probeCommonPorts(); } + const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner .run({ command: "lsof", @@ -257,7 +273,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), - Effect.catchCause(() => Effect.succeed(null)), + Effect.catchTags({ + ProcessSpawnError: recoverLsofProbeFailure, + ProcessStdinError: recoverLsofProbeFailure, + ProcessOutputLimitError: recoverLsofProbeFailure, + ProcessReadError: recoverLsofProbeFailure, + ProcessTimeoutError: recoverLsofProbeFailure, + }), ); if (lsofResult !== null) return lsofResult; return yield* probeCommonPorts(); From aab72f10c1227b7bbf9cce76644e374e275a4e1e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:41 -0700 Subject: [PATCH 29/80] [codex] Report markdown interaction failures (#3355) Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 263 +++++++++++++++-------- 1 file changed, 174 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1d8920e4812d..711a545d90ae 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -120,6 +120,20 @@ const EMPTY_MARKDOWN_SKILLS: ReadonlyArray( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, @@ -332,7 +346,9 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { copiedTimerRef.current = null; }, 1200); }) - .catch(() => undefined); + .catch((cause) => { + reportMarkdownActionFailure({ operation: "copy-table", format }, cause); + }); }, []); useEffect( @@ -529,8 +545,17 @@ function MarkdownCodeBlock({ copiedTimerRef.current = null; }, 1200); }) - .catch(() => undefined); - }, [code]); + .catch((cause) => { + reportMarkdownActionFailure( + { + operation: "copy-code-block", + language, + ...(fenceTitle ? { fenceTitle } : {}), + }, + cause, + ); + }); + }, [code, fenceTitle, language]); useEffect( () => () => { @@ -977,18 +1002,36 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { void (async () => { - const result = await onOpen(targetPath); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; + try { + const result = await onOpen(targetPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "open-file-in-editor", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "open-file-in-editor", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); })(); }, [onOpen, targetPath]); @@ -1005,52 +1048,77 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } void (async () => { - const result = await onOpenInBrowser(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; + try { + const result = await onOpenInBrowser(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "open-file-in-browser", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "open-file-in-browser", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); })(); - }, [onOpenInBrowser]); - - const handleCopy = useCallback((value: string, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } + }, [onOpenInBrowser, targetPath]); - void navigator.clipboard.writeText(value).then( - () => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: value, - }); - }, - (error) => { + const handleCopy = useCallback( + (value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", + description: "Clipboard API unavailable.", }), ); - }, - ); - }, []); + return; + } + + void navigator.clipboard.writeText(value).then( + () => { + toastManager.add({ + type: "success", + title: `${title} copied`, + description: value, + }); + }, + (error) => { + reportMarkdownActionFailure( + { operation: "copy-file-path", target: targetPath, copyTarget: title }, + error, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, + [targetPath], + ); const handleContextMenu = useCallback( async (event: ReactMouseEvent) => { @@ -1060,32 +1128,39 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "open", label: "Open in editor" }, - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - { id: "copy-relative", label: "Copy relative path" }, - { id: "copy-full", label: "Copy full path" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); + try { + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open in editor" }, + ...(onOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); - return; - } - if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); + if (clicked === "open") { + handleOpenInEditor(); + return; + } + if (clicked === "open-in-browser") { + handleOpenInBrowser(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + } catch (cause) { + reportMarkdownActionFailure( + { operation: "show-file-context-menu", target: targetPath }, + cause, + ); } }, [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], @@ -1315,22 +1390,32 @@ function ChatMarkdown({ event.stopPropagation(); const api = readLocalApi(); if (!api) return; - void api.contextMenu - .show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ) - .then((clicked) => { + void (async () => { + let operation = "show-link-context-menu"; + try { + const clicked = await api.contextMenu.show( + [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); if (clicked === "open-in-browser") { - void openExternalLinkInPreview(href); + operation = "open-link-in-preview"; + const result = await openExternalLinkInPreview(href); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure({ operation, target: href }, result.cause); + } return; } - if (clicked === "open-external") return api.shell.openExternal(href); - }) - .catch(() => undefined); + if (clicked === "open-external") { + operation = "open-link-external"; + await api.shell.openExternal(href); + } + } catch (cause) { + reportMarkdownActionFailure({ operation, target: href }, cause); + } + })(); }} > {faviconHost ? ( From 9a78c6f2bab95bdb899dffcefd50fa52f7b61c07 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:46 -0700 Subject: [PATCH 30/80] [codex] Structure web local storage failures (#3350) Co-authored-by: codex --- apps/web/src/clientPersistenceStorage.test.ts | 18 +++ apps/web/src/clientPersistenceStorage.ts | 3 +- .../src/components/files/FilePreviewPanel.tsx | 13 +- apps/web/src/hooks/useLocalStorage.test.ts | 121 ++++++++++++++++++ apps/web/src/hooks/useLocalStorage.ts | 99 ++++++++++---- apps/web/src/hooks/useResizableWidth.ts | 7 +- apps/web/src/providerUpdateDismissal.ts | 7 +- apps/web/src/versionSkew.ts | 7 +- 8 files changed, 237 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/hooks/useLocalStorage.test.ts diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 6c449eea2b11..ec335892bea8 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -51,4 +51,22 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); + + it("reports structured decode failures while preserving the fallback", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(readBrowserClientSettings()).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + "Could not read persisted client settings.", + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + cause: expect.anything(), + }), + ); + }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index b6a9f1f8e032..5c0ba7c6eccf 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -15,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null { try { return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch { + } catch (error) { + console.error("Could not read persisted client settings.", error); return null; } } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ba0be2da2da5..89176cd45254 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -12,12 +12,14 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useTheme } from "~/hooks/useTheme"; +import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -589,8 +591,9 @@ function RenderedMarkdownSurface({ function initialExplorerOpen(): boolean { try { - return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false"; - } catch { + return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; + } catch (error) { + console.error(error); return true; } } @@ -650,8 +653,10 @@ export default function FilePreviewPanel({ setExplorerOpen((current) => { const next = !current; try { - window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next)); - } catch {} + setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, next, Schema.Boolean); + } catch (error) { + console.error(error); + } return next; }); }; diff --git a/apps/web/src/hooks/useLocalStorage.test.ts b/apps/web/src/hooks/useLocalStorage.test.ts new file mode 100644 index 000000000000..27627a36e4b0 --- /dev/null +++ b/apps/web/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,121 @@ +import * as Schema from "effect/Schema"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +async function loadWithStorage(storage: Storage) { + vi.stubGlobal("window", { localStorage: storage }); + vi.stubGlobal("localStorage", storage); + return import("./useLocalStorage"); +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe("local storage errors", () => { + it("preserves read failure context", async () => { + const cause = new Error("storage unavailable"); + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ + getItem: () => { + throw cause; + }, + }), + ); + + try { + getLocalStorageItem("read-key", Schema.String); + expect.unreachable("expected the read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "read-key", + cause, + }); + } + }); + + it("preserves decode failure context", async () => { + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ getItem: () => "not-json" }), + ); + + try { + getLocalStorageItem("decode-key", Schema.String); + expect.unreachable("expected decoding to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "decode", + storageKey: "decode-key", + cause: expect.anything(), + }); + } + }); + + it("preserves write failure context", async () => { + const cause = new Error("storage quota exceeded"); + const { LocalStorageOperationError, setLocalStorageItem } = await loadWithStorage( + createStorage({ + setItem: () => { + throw cause; + }, + }), + ); + + try { + setLocalStorageItem("write-key", "value", Schema.String); + expect.unreachable("expected the write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "write-key", + cause, + }); + } + }); + + it("preserves removal failure context", async () => { + const cause = new Error("storage unavailable"); + const { LocalStorageOperationError, removeLocalStorageItem } = await loadWithStorage( + createStorage({ + removeItem: () => { + throw cause; + }, + }), + ); + + try { + removeLocalStorageItem("remove-key"); + expect.unreachable("expected the removal to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "remove", + storageKey: "remove-key", + cause, + }); + } + }); +}); diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 50e81dbc0b89..3099e73ff43f 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -2,6 +2,19 @@ import * as Schema from "effect/Schema"; import * as Record from "effect/Record"; import { useCallback, useMemo, useSyncExternalStore } from "react"; +export class LocalStorageOperationError extends Schema.TaggedErrorClass()( + "LocalStorageOperationError", + { + operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} local storage item ${this.storageKey}.`; + } +} + const isomorphicLocalStorage: Storage = typeof window !== "undefined" ? window.localStorage @@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage = }; })(); -const decode = (schema: Schema.Codec, value: string) => { - const decodeJson = Schema.decodeSync(Schema.fromJsonString(schema)); - return decodeJson(value); +const read = (key: string) => { + try { + return isomorphicLocalStorage.getItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); + } +}; + +const decode = (key: string, schema: Schema.Codec, value: string) => { + try { + return Schema.decodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause }); + } }; -const encode = (schema: Schema.Codec, value: T) => { - const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema)); - return encodeJson(value); +const encode = (key: string, schema: Schema.Codec, value: T) => { + try { + return Schema.encodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause }); + } }; export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { - const item = isomorphicLocalStorage.getItem(key); - return item ? decode(schema, item) : null; + const item = read(key); + return item ? decode(key, schema, item) : null; }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { - const valueToSet = encode(schema, value); - isomorphicLocalStorage.setItem(key, valueToSet); + const valueToSet = encode(key, schema, value); + try { + isomorphicLocalStorage.setItem(key, valueToSet); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); + } }; export const removeLocalStorageItem = (key: string) => { - isomorphicLocalStorage.removeItem(key); + try { + isomorphicLocalStorage.removeItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); + } }; const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; @@ -51,11 +86,15 @@ interface LocalStorageChangeDetail { function dispatchLocalStorageChange(key: string) { if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { - detail: { key }, - }), - ); + try { + window.dispatchEvent( + new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { + detail: { key }, + }), + ); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause }); + } } export function useLocalStorage( @@ -65,9 +104,9 @@ export function useLocalStorage( ): [T, (value: T | ((val: T) => T)) => void] { const getSnapshot = useCallback(() => { try { - return isomorphicLocalStorage.getItem(key); + return read(key); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not read stored value.", error); return null; } }, [key]); @@ -101,19 +140,31 @@ export function useLocalStorage( return initialValue; } try { - return decode(schema, serializedValue); + return decode(key, schema, serializedValue); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not decode stored value.", error); return initialValue; } - }, [initialValue, schema, serializedValue]); + }, [initialValue, key, schema, serializedValue]); const setValue = useCallback( (value: T | ((val: T) => T)) => { try { const currentValue = getLocalStorageItem(key, schema) ?? initialValue; - const valueToStore = - typeof value === "function" ? (value as (val: T) => T)(currentValue) : value; + let valueToStore: T; + if (typeof value === "function") { + try { + valueToStore = (value as (val: T) => T)(currentValue); + } catch (cause) { + throw new LocalStorageOperationError({ + operation: "update", + storageKey: key, + cause, + }); + } + } else { + valueToStore = value; + } if (valueToStore === null) { removeLocalStorageItem(key); } else { @@ -121,7 +172,7 @@ export function useLocalStorage( } dispatchLocalStorageChange(key); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not update stored value.", error); } }, [initialValue, key, schema], diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index d3c7207c185d..08c067471f74 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -55,7 +55,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { try { const stored = getLocalStorageItem(storageKey, WidthSchema); return clamp(stored ?? defaultWidth); - } catch { + } catch (error) { + console.error("Could not read persisted panel width.", error); return defaultWidth; } }); @@ -141,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { // Commit once at drag-end to avoid 60Hz localStorage writes. try { setLocalStorageItem(storageKey, finalWidth, WidthSchema); - } catch { - // localStorage may be full / disabled; the in-memory state still wins. + } catch (error) { + console.error("Could not persist panel width.", error); } setWidth(finalWidth); }, diff --git a/apps/web/src/providerUpdateDismissal.ts b/apps/web/src/providerUpdateDismissal.ts index 7cce819ccf95..28789152b34c 100644 --- a/apps/web/src/providerUpdateDismissal.ts +++ b/apps/web/src/providerUpdateDismissal.ts @@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals { keys: [], } ); - } catch { + } catch (error) { + console.error("Could not read provider-update dismissals.", error); return { keys: [] }; } } @@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void document, ProviderUpdateDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the toast. + } catch (error) { + console.error("Could not persist provider-update dismissals.", error); } } diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index cb0116c85506..88691cfc25e4 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals { VersionMismatchDismissalsSchema, ) ?? { keys: [] } ); - } catch { + } catch (error) { + console.error("Could not read version-mismatch dismissals.", error); return { keys: [] }; } } @@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo document, VersionMismatchDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the banner. + } catch (error) { + console.error("Could not persist version-mismatch dismissals.", error); } } From 8ce627ba91a6c4e5b336bc50c2fa06ebf4f4ade6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:51 -0700 Subject: [PATCH 31/80] [codex] Structure mobile thread outbox failures (#3341) Co-authored-by: codex --- .../mobile/src/state/thread-outbox-manager.ts | 87 ++++++++++++++-- .../mobile/src/state/thread-outbox-storage.ts | 98 +++++++++++++++---- apps/mobile/src/state/thread-outbox.test.ts | 53 +++++++++- 3 files changed, 208 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index 477cb1273a3c..7762e6cdf789 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -1,4 +1,5 @@ -import type { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; import { @@ -8,6 +9,27 @@ import { } from "./thread-outbox-model"; import type { ThreadOutboxStorage } from "./thread-outbox-storage"; +export class ThreadOutboxManagerError extends Schema.TaggedErrorClass()( + "ThreadOutboxManagerError", + { + operation: Schema.Literals([ + "load", + "enqueue", + "remove", + "clear-environment-load", + "clear-environment-remove", + ]), + environmentId: Schema.NullOr(EnvironmentId), + threadId: Schema.NullOr(ThreadId), + messageId: Schema.NullOr(MessageId), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Thread outbox operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}.`; + } +} + export interface ThreadOutboxManagerOptions { readonly registry: AtomRegistry.AtomRegistry; readonly storage: ThreadOutboxStorage; @@ -49,22 +71,51 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { loadPromise = serialize(async () => { const persistedMessages = await options.storage.load(); setMessages([...persistedMessages, ...currentMessages()]); - }).catch((error) => { + }).catch((cause) => { loadPromise = null; - warn("[thread-outbox] failed to load persisted messages", error); + warn( + "[thread-outbox] failed to load persisted messages", + new ThreadOutboxManagerError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + cause, + }), + ); }); return loadPromise; }; const enqueue = (message: QueuedThreadMessage): Promise => serialize(async () => { - await options.storage.write(message); + try { + await options.storage.write(message); + } catch (cause) { + throw new ThreadOutboxManagerError({ + operation: "enqueue", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }); + } setMessages([...currentMessages(), message]); }); const remove = (message: QueuedThreadMessage): Promise => serialize(async () => { - await options.storage.remove(message); + try { + await options.storage.remove(message); + } catch (cause) { + throw new ThreadOutboxManagerError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }); + } setMessages( currentMessages().filter((candidate) => candidate.messageId !== message.messageId), ); @@ -72,8 +123,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { const clearEnvironment = (environmentId: EnvironmentId): Promise => serialize(async () => { - const persisted = await options.storage.load().catch((error) => { - warn("[thread-outbox] failed to load messages while clearing environment", error); + const persisted = await options.storage.load().catch((cause) => { + warn( + "[thread-outbox] failed to load messages while clearing environment", + new ThreadOutboxManagerError({ + operation: "clear-environment-load", + environmentId, + threadId: null, + messageId: null, + cause, + }), + ); return []; }); const allMessages = flattenQueuedThreadMessages( @@ -88,8 +148,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { try { await options.storage.remove(message); removedMessageIds.add(message.messageId); - } catch (error) { - warn("[thread-outbox] failed to clear persisted message", error); + } catch (cause) { + warn( + "[thread-outbox] failed to clear persisted message", + new ThreadOutboxManagerError({ + operation: "clear-environment-remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }), + ); } }), ); diff --git a/apps/mobile/src/state/thread-outbox-storage.ts b/apps/mobile/src/state/thread-outbox-storage.ts index e294aee4549d..2003c220badb 100644 --- a/apps/mobile/src/state/thread-outbox-storage.ts +++ b/apps/mobile/src/state/thread-outbox-storage.ts @@ -1,4 +1,5 @@ -import type { MessageId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { decodeQueuedThreadMessage, @@ -8,6 +9,22 @@ import { const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; +export class ThreadOutboxStorageError extends Schema.TaggedErrorClass()( + "ThreadOutboxStorageError", + { + operation: Schema.Literals(["load", "read-message", "write", "remove"]), + environmentId: Schema.NullOr(EnvironmentId), + threadId: Schema.NullOr(ThreadId), + messageId: Schema.NullOr(MessageId), + fileName: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Thread outbox storage operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}, file ${this.fileName ?? "unknown"}.`; + } +} + export interface ThreadOutboxStorage { readonly load: () => Promise>; readonly write: (message: QueuedThreadMessage) => Promise; @@ -32,33 +49,78 @@ async function getMessageFile(messageId: MessageId) { export const expoThreadOutboxStorage: ThreadOutboxStorage = { load: async () => { - const { File } = await import("expo-file-system"); - const directory = await getOutboxDirectory(); const messages: QueuedThreadMessage[] = []; + try { + const { File } = await import("expo-file-system"); + const directory = await getOutboxDirectory(); - for (const entry of directory.list()) { - if (!(entry instanceof File) || !entry.name.endsWith(".json")) { - continue; - } - try { - messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); - } catch (error) { - console.warn("[thread-outbox] ignored invalid persisted message", entry.name, error); + for (const entry of directory.list()) { + if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + continue; + } + try { + messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); + } catch (cause) { + console.warn( + "[thread-outbox] ignored invalid persisted message", + new ThreadOutboxStorageError({ + operation: "read-message", + environmentId: null, + threadId: null, + messageId: null, + fileName: entry.name, + cause, + }), + ); + } } + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + fileName: null, + cause, + }); } return messages; }, write: async (message) => { - const file = await getMessageFile(message.messageId); - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); + const fileName = messageFileName(message.messageId); + try { + const file = await getMessageFile(message.messageId); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encodeQueuedThreadMessage(message))); + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "write", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + fileName, + cause, + }); } - file.write(JSON.stringify(encodeQueuedThreadMessage(message))); }, remove: async (message) => { - const file = await getMessageFile(message.messageId); - if (file.exists) { - file.delete(); + const fileName = messageFileName(message.messageId); + try { + const file = await getMessageFile(message.messageId); + if (file.exists) { + file.delete(); + } + } catch (cause) { + throw new ThreadOutboxStorageError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + fileName, + cause, + }); } }, }; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d2634fb966f2..d6b91c1c4f60 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -10,7 +10,7 @@ import { threadOutboxRetryDelayMs, type QueuedThreadMessage, } from "./thread-outbox-model"; -import { createThreadOutboxManager } from "./thread-outbox-manager"; +import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; import type { ThreadOutboxStorage } from "./thread-outbox-storage"; function queuedMessage(input: { @@ -149,9 +149,48 @@ describe("thread outbox", () => { registry.dispose(); }); + it("reports structured load failures and permits a retry", async () => { + const registry = AtomRegistry.make(); + const loadCause = new Error("storage unavailable"); + const warnings: Array<{ message: string; error: unknown }> = []; + let loadCalls = 0; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => { + loadCalls += 1; + if (loadCalls === 1) throw loadCause; + return []; + }, + write: async () => undefined, + remove: async () => undefined, + }, + warn: (message, error) => warnings.push({ message, error }), + }); + + await manager.load(); + expect(warnings).toEqual([ + { + message: "[thread-outbox] failed to load persisted messages", + error: new ThreadOutboxManagerError({ + operation: "load", + environmentId: null, + threadId: null, + messageId: null, + cause: loadCause, + }), + }, + ]); + + await manager.load(); + expect(loadCalls).toBe(2); + registry.dispose(); + }); + it("keeps atom state aligned with durable writes and removals", async () => { const registry = AtomRegistry.make(); const stored = new Map(); + const removalCause = new Error("remove failed"); let failRemoval = true; const storage: ThreadOutboxStorage = { load: async () => [...stored.values()], @@ -160,7 +199,7 @@ describe("thread outbox", () => { }, remove: async (message) => { if (failRemoval) { - throw new Error("remove failed"); + throw removalCause; } stored.delete(message.messageId); }, @@ -176,7 +215,15 @@ describe("thread outbox", () => { "environment-1:thread-1": [message], }); - await expect(manager.remove(message)).rejects.toThrow("remove failed"); + await expect(manager.remove(message)).rejects.toEqual( + new ThreadOutboxManagerError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause: removalCause, + }), + ); expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ "environment-1:thread-1": [message], }); From 834e5db45843d8b5da4fbfe1999fbaa4c58e117b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:14:55 -0700 Subject: [PATCH 32/80] [codex] structure project CLI failures (#3339) Co-authored-by: codex --- apps/server/src/cli/project.test.ts | 32 +++++++ apps/server/src/cli/project.ts | 129 ++++++++++++++++++---------- 2 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/cli/project.test.ts diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts new file mode 100644 index 000000000000..4d7e47ce5416 --- /dev/null +++ b/apps/server/src/cli/project.test.ts @@ -0,0 +1,32 @@ +import { assert, it } from "@effect/vitest"; + +import { EnvironmentInternalError } from "@t3tools/contracts"; + +import { ProjectCommandError } from "./project.ts"; + +it("maps declared server failures into structural project command errors", () => { + const cause = new EnvironmentInternalError({ + code: "internal_error", + reason: "orchestration_snapshot_failed", + traceId: "trace-123", + }); + + const error = ProjectCommandError.fromLiveServerRequest(cause); + + assert.strictEqual(error.operation, "callLiveServer"); + assert.strictEqual(error.code, "internal_error"); + assert.strictEqual(error.traceId, "trace-123"); + assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123)."); + assert.strictEqual(error.cause, cause); +}); + +it("preserves unexpected server failures without deriving the message from them", () => { + const cause = new Error("credential abc123 was rejected"); + + const error = ProjectCommandError.fromLiveServerRequest(cause); + + assert.strictEqual(error.operation, "callLiveServer"); + assert.strictEqual(error.detail, "Failed to call the running server."); + assert.strictEqual(error.message, "Failed to call the running server."); + assert.strictEqual(error.cause, cause); +}); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index d52d5b214d80..16f1f0e14d72 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -9,11 +9,9 @@ import { } from "@t3tools/contracts"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -52,16 +50,64 @@ type ProjectCliDispatchCommand = Extract< { type: "project.create" | "project.meta.update" | "project.delete" } >; -class ProjectCommandError extends Data.TaggedError("ProjectCommandError")<{ - readonly message: string; -}> {} +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); +const ProjectCommandOperation = Schema.Literals([ + "generateProjectCommandId", + "callLiveServer", + "validateProjectTitle", + "resolveProjectTarget", + "addProject", +]); + +export class ProjectCommandError extends Schema.TaggedErrorClass()( + "ProjectCommandError", + { + operation: ProjectCommandOperation, + detail: Schema.String, + code: Schema.optional(Schema.String), + traceId: Schema.optional(Schema.String), + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + static fromLiveServerRequest(cause: unknown): ProjectCommandError { + if (isEnvironmentHttpCommonError(cause)) { + return new ProjectCommandError({ + operation: "callLiveServer", + detail: `Server request failed (${cause.code}, trace ${cause.traceId}).`, + code: cause.code, + traceId: cause.traceId, + cause, + }); + } + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + return new ProjectCommandError({ + operation: "callLiveServer", + detail: `Server request failed with undeclared status ${cause.response.status}.`, + status: cause.response.status, + cause, + }); + } + return new ProjectCommandError({ + operation: "callLiveServer", + detail: "Failed to call the running server.", + cause, + }); + } + + override get message(): string { + return this.detail; + } +} const projectCommandUuid = Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.mapError( - () => + (cause) => new ProjectCommandError({ - message: "Failed to generate a project command identifier.", + operation: "generateProjectCommandId", + detail: "Failed to generate a project command identifier.", + cause, }), ), ); @@ -75,7 +121,6 @@ const ProjectCliRuntimeLive = Layer.mergeAll( ); const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); -const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); const withProjectCliSessionToken = ( environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, @@ -92,28 +137,6 @@ const withProjectCliSessionToken = ( const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); -const failLiveServerRequest = (cause: unknown) => { - if (isEnvironmentHttpCommonError(cause)) { - return Effect.fail( - new ProjectCommandError({ - message: `Server request failed (${cause.code}, trace ${cause.traceId}).`, - }), - ); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - return Effect.fail( - new ProjectCommandError({ - message: `Server request failed with undeclared status ${cause.response.status}.`, - }), - ); - } - return Effect.fail( - new ProjectCommandError({ - message: `Failed to call running server: ${String(cause)}.`, - }), - ); -}; - const makeLiveServerClient = (origin: string) => HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin, @@ -135,7 +158,10 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( if (trimmed.length > 0) { return trimmed; } - return yield* new ProjectCommandError({ message: "Project title cannot be empty." }); + return yield* new ProjectCommandError({ + operation: "validateProjectTitle", + detail: "Project title cannot be empty.", + }); } const path = yield* Path.Path; @@ -149,7 +175,10 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }) { const trimmedIdentifier = input.identifier.trim(); if (trimmedIdentifier.length === 0) { - return yield* new ProjectCommandError({ message: "Project identifier cannot be empty." }); + return yield* new ProjectCommandError({ + operation: "resolveProjectTarget", + detail: "Project identifier cannot be empty.", + }); } const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); @@ -162,12 +191,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( } satisfies ProjectMutationTarget; } - const normalizedWorkspaceRootResult = yield* Effect.exit( + const normalizedWorkspaceRootResult = yield* Effect.result( normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), ); - const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) - ? normalizedWorkspaceRootResult.value - : null; + const normalizedWorkspaceRoot = + normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null; const exactWorkspaceMatch = normalizedWorkspaceRoot === null @@ -177,7 +205,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( const resolved = exactWorkspaceMatch; if (!resolved) { return yield* new ProjectCommandError({ - message: `No active project found for '${trimmedIdentifier}'.`, + operation: "resolveProjectTarget", + detail: `No active project found for '${trimmedIdentifier}'.`, + ...(normalizedWorkspaceRootResult._tag === "Failure" + ? { cause: normalizedWorkspaceRootResult.failure } + : {}), }); } @@ -194,7 +226,10 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => return yield* client.orchestration.snapshot({ headers: { authorization: `Bearer ${bearerToken}` }, }); - }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); + }).pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(ProjectCommandError.fromLiveServerRequest), + ); const dispatchLiveOrchestrationCommand = ( origin: string, @@ -207,7 +242,10 @@ const dispatchLiveOrchestrationCommand = ( headers: { authorization: `Bearer ${bearerToken}` }, payload: command, } as Parameters[0]); - }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); + }).pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(ProjectCommandError.fromLiveServerRequest), + ); const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -232,11 +270,15 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu ), ); - const attempted = yield* Effect.exit(attempt); - if (Exit.isSuccess(attempted)) { - return Option.some(attempted.value); + const attempted = yield* Effect.result(attempt); + if (attempted._tag === "Success") { + return Option.some(attempted.success); } + yield* Effect.logDebug("Failed to connect to the persisted project CLI server.", { + origin: runtimeState.value.origin, + cause: attempted.failure, + }); yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); return Option.none<{ readonly origin: string }>(); }, @@ -335,7 +377,8 @@ const projectAddCommand = Command.make("add", { ); if (existingProject) { return yield* new ProjectCommandError({ - message: `An active project already exists for '${workspaceRoot}'.`, + operation: "addProject", + detail: `An active project already exists for '${workspaceRoot}'.`, }); } From 42ff43915a2ed5c0f190019c0c1a6a1cb68658c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:15:00 -0700 Subject: [PATCH 33/80] [codex] Preserve mobile review highlighter failures (#3337) Co-authored-by: codex --- .../diffs/nativeReviewDiffHighlighter.ts | 81 ++++++++++++++++--- .../review/reviewHighlighterState.test.ts | 27 +++++-- .../features/review/reviewHighlighterState.ts | 55 +++++++++---- .../features/review/shikiReviewHighlighter.ts | 61 ++++++++++---- .../review/useNativeReviewDiffHighlighting.ts | 6 +- 5 files changed, 182 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 72abcc8c9565..6c8c957f5410 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -8,6 +8,7 @@ import jsxLanguage from "@shikijs/langs/jsx"; import tsxLanguage from "@shikijs/langs/tsx"; import typescriptLanguage from "@shikijs/langs/typescript"; import yamlLanguage from "@shikijs/langs/yaml"; +import * as Schema from "effect/Schema"; import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes"; import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface"; @@ -15,6 +16,32 @@ import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewD export type NativeReviewDiffHighlightScheme = "light" | "dark"; export type NativeReviewDiffHighlightEngine = "native" | "javascript"; +export class NativeReviewDiffHighlighterUnavailableError extends Schema.TaggedErrorClass()( + "NativeReviewDiffHighlighterUnavailableError", + {}, +) { + override get message(): string { + return "The native review diff highlighter is unavailable in this build."; + } +} + +export const isNativeReviewDiffHighlighterUnavailableError = Schema.is( + NativeReviewDiffHighlighterUnavailableError, +); + +export class NativeReviewDiffHighlighterInitializationError extends Schema.TaggedErrorClass()( + "NativeReviewDiffHighlighterInitializationError", + { + requestedEngine: Schema.Literals(["native", "javascript"]), + attemptedEngine: Schema.Literals(["native", "javascript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the ${this.attemptedEngine} review diff highlighter requested as ${this.requestedEngine}.`; + } +} + export interface NativeReviewDiffHighlighterHandle { readonly engine: NativeReviewDiffHighlightEngine; readonly tokenize: ( @@ -197,7 +224,7 @@ function normalizeTokens( async function createNativeReviewDiffHighlighter(): Promise { const nativeEngineModule = await import("react-native-shiki-engine"); if (!nativeEngineModule.isNativeEngineAvailable()) { - throw new Error("Native Shiki engine is not available in this build."); + throw new NativeReviewDiffHighlighterUnavailableError(); } const highlighter = await createHighlighterCore({ @@ -229,18 +256,52 @@ export async function getNativeReviewDiffHighlighter( engine: NativeReviewDiffHighlightEngine = "native", ): Promise { if (engine === "javascript") { - javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); - return javascriptHighlighterPromise; + try { + javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); + return await javascriptHighlighterPromise; + } catch (cause) { + javascriptHighlighterPromise = null; + throw new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "javascript", + cause, + }); + } } - nativeHighlighterPromise ??= createNativeReviewDiffHighlighter().catch((error: unknown) => { - console.warn("[debug-native-diff] native highlighter unavailable", { - error: error instanceof Error ? error.message : String(error), + nativeHighlighterPromise ??= createNativeReviewDiffHighlighter() + .catch(async (cause: unknown) => { + const nativeError = isNativeReviewDiffHighlighterUnavailableError(cause) + ? cause + : new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "native", + cause, + }); + console.warn("[debug-native-diff] native highlighter unavailable", { + error: nativeError, + }); + try { + javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); + return await javascriptHighlighterPromise; + } catch (fallbackCause) { + javascriptHighlighterPromise = null; + throw new NativeReviewDiffHighlighterInitializationError({ + requestedEngine: engine, + attemptedEngine: "javascript", + cause: new AggregateError( + [nativeError, fallbackCause], + "Native and JavaScript review diff highlighter initialization failed.", + { cause: nativeError }, + ), + }); + } + }) + .catch((error) => { + nativeHighlighterPromise = null; + throw error; }); - javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); - return javascriptHighlighterPromise; - }); - return nativeHighlighterPromise; + return await nativeHighlighterPromise; } function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow { diff --git a/apps/mobile/src/features/review/reviewHighlighterState.test.ts b/apps/mobile/src/features/review/reviewHighlighterState.test.ts index 43ec2e041823..9cc43d07f2af 100644 --- a/apps/mobile/src/features/review/reviewHighlighterState.test.ts +++ b/apps/mobile/src/features/review/reviewHighlighterState.test.ts @@ -53,11 +53,12 @@ it("initializes review highlighter state once", async () => { }); it("stores initialization failures in atom state", async () => { + const cause = new Error("load failed"); const manager = createReviewHighlighterManager({ getRegistry: () => registry, loader: { prepare: async () => { - throw new Error("load failed"); + throw cause; }, prepareLanguages: async () => undefined, getEngine: async () => "javascript", @@ -67,9 +68,23 @@ it("stores initialization failures in atom state", async () => { void manager.initialize(); await flushAsyncWork(); - assert.deepStrictEqual(manager.getSnapshot(), { - engine: null, - error: "load failed", - status: "error", - }); + const snapshot = manager.getSnapshot(); + assert.strictEqual(snapshot.engine, null); + assert.strictEqual(snapshot.status, "error"); + assert.strictEqual(snapshot.error?._tag, "ReviewHighlighterManagerError"); + assert.strictEqual(snapshot.error?.operation, "prepare"); + assert.deepStrictEqual(snapshot.error?.languages, [ + "typescript", + "tsx", + "javascript", + "jsx", + "json", + "yaml", + "bash", + ]); + assert.strictEqual(snapshot.error?.cause, cause); + assert.strictEqual( + snapshot.error?.message, + "Review highlighter operation prepare failed for languages typescript, tsx, javascript, jsx, json, yaml, bash.", + ); }); diff --git a/apps/mobile/src/features/review/reviewHighlighterState.ts b/apps/mobile/src/features/review/reviewHighlighterState.ts index 2622ecbe0500..51b20bb07ff6 100644 --- a/apps/mobile/src/features/review/reviewHighlighterState.ts +++ b/apps/mobile/src/features/review/reviewHighlighterState.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; import { useEffect } from "react"; @@ -12,9 +13,22 @@ import { export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error"; +export class ReviewHighlighterManagerError extends Schema.TaggedErrorClass()( + "ReviewHighlighterManagerError", + { + operation: Schema.Literals(["prepare", "prepare-languages", "resolve-engine"]), + languages: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Review highlighter operation ${this.operation} failed for languages ${this.languages.join(", ")}.`; + } +} + export interface ReviewHighlighterState { readonly engine: ReviewHighlighterEngine | null; - readonly error: string | null; + readonly error: ReviewHighlighterManagerError | null; readonly status: ReviewHighlighterStatus; } @@ -101,24 +115,35 @@ export function createReviewHighlighterManager(config: { inFlight = (async () => { const startedAt = performance.now(); + const languages = config.languages ?? REVIEW_INITIAL_LANGUAGES; + let operation: ReviewHighlighterManagerError["operation"] = "prepare"; + let engine: ReviewHighlighterEngine; try { await config.loader.prepare(); - await config.loader.prepareLanguages(config.languages ?? REVIEW_INITIAL_LANGUAGES); - const engine = await config.loader.getEngine(); - const durationMs = Math.round(performance.now() - startedAt); - logReviewHighlighterProviderDiagnostic("initialized", { - durationMs, - engine, + operation = "prepare-languages"; + await config.loader.prepareLanguages(languages); + operation = "resolve-engine"; + engine = await config.loader.getEngine(); + } catch (cause) { + const error = new ReviewHighlighterManagerError({ + operation, + languages, + cause, }); - setState({ engine, error: null, status: "ready" }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logReviewHighlighterProviderDiagnostic("initialization failed", { error: message }); - setState({ engine: null, error: message, status: "error" }); - } finally { - inFlight = null; + logReviewHighlighterProviderDiagnostic("initialization failed", { error }); + setState({ engine: null, error, status: "error" }); + return; } - })(); + + const durationMs = Math.round(performance.now() - startedAt); + logReviewHighlighterProviderDiagnostic("initialized", { + durationMs, + engine, + }); + setState({ engine, error: null, status: "ready" }); + })().finally(() => { + inFlight = null; + }); return inFlight; } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 7030ac77e5fc..008a07619490 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -10,6 +10,7 @@ import yamlLanguage from "@shikijs/langs/yaml"; import githubDarkDefault from "@shikijs/themes/github-dark-default"; import githubLightDefault from "@shikijs/themes/github-light-default"; import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; +import * as Schema from "effect/Schema"; import { resolveReviewHighlighterEngine, @@ -22,6 +23,19 @@ import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordD export type ReviewDiffTheme = "light" | "dark"; export type { ReviewHighlighterEngine }; +export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErrorClass()( + "ReviewHighlighterEngineInitializationError", + { + preferredEngine: Schema.Literals(["native", "javascript"]), + attemptedEngine: Schema.Literals(["native", "javascript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the ${this.attemptedEngine} review highlighter with ${this.preferredEngine} preferred.`; + } +} + export interface ReviewHighlightedToken { content: string; readonly color: string | null; @@ -227,16 +241,6 @@ function logReviewHighlighterDiagnosticError(message: string, error: unknown): v if (!isReviewHighlighterDebugLoggingEnabled()) { return; } - - if (error instanceof Error) { - console.error(`[review-highlighter] ${message}`, { - name: error.name, - message: error.message, - stack: error.stack, - }); - return; - } - console.error(`[review-highlighter] ${message}`, error); } @@ -258,6 +262,7 @@ async function getHighlighter(): Promise { if (!highlighterPromise) { const configuredHighlighterPromise = (async () => { let nativeEngineAvailable = false; + let nativeInitializationError: ReviewHighlighterEngineInitializationError | undefined; logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, @@ -289,9 +294,14 @@ async function getHighlighter(): Promise { }; } } catch (error) { + nativeInitializationError = new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "native", + cause: error, + }); logReviewHighlighterDiagnosticError( "native engine initialization failed; falling back to javascript", - error, + nativeInitializationError, ); nativeEngineAvailable = false; } @@ -305,11 +315,30 @@ async function getHighlighter(): Promise { REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, nativeEngineAvailable, ); - const highlighter = await createHighlighterCore({ - themes, - langs: REVIEW_INITIAL_LANGUAGE_MODULES, - engine: createJavaScriptRegexEngine(), - }); + let highlighter: HighlighterCore; + try { + highlighter = await createHighlighterCore({ + themes, + langs: REVIEW_INITIAL_LANGUAGE_MODULES, + engine: createJavaScriptRegexEngine(), + }); + } catch (cause) { + const javascriptError = new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "javascript", + cause, + }); + if (!nativeInitializationError) throw javascriptError; + throw new ReviewHighlighterEngineInitializationError({ + preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + attemptedEngine: "javascript", + cause: new AggregateError( + [nativeInitializationError, javascriptError], + "Native and JavaScript review highlighter initialization failed.", + { cause: nativeInitializationError }, + ), + }); + } logReviewHighlighterDiagnostic("using javascript engine", { resolvedEngine: engine, }); diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 98df26641a98..35f06c263666 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -108,7 +108,11 @@ export function useNativeReviewDiffHighlighting(input: { } catch (error) { if (!abortController.signal.aborted) { logReviewDiffDiagnostic("native visible highlight failed", { - error: error instanceof Error ? error.message : String(error), + error, + resetKey, + scheme, + firstRowIndex: requestRange.firstRowIndex, + lastRowIndex: requestRange.lastRowIndex, }); } } From 90f1936fade32015253aadff6d43f2271580f4c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:15:05 -0700 Subject: [PATCH 34/80] [codex] Structure server runtime-state failures (#3319) Co-authored-by: codex --- apps/server/src/serverRuntimeState.test.ts | 167 +++++++++++++++++++++ apps/server/src/serverRuntimeState.ts | 92 +++++++++++- 2 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/serverRuntimeState.test.ts diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts new file mode 100644 index 000000000000..749fd3062e91 --- /dev/null +++ b/apps/server/src/serverRuntimeState.test.ts @@ -0,0 +1,167 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; + +import * as ServerRuntimeState from "./serverRuntimeState.ts"; + +const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError); + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +describe("serverRuntimeState", () => { + it.effect("persists and reads the runtime state", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "runtime", "server.json"); + const state: ServerRuntimeState.PersistedServerRuntimeState = { + version: 1, + pid: 123, + host: "127.0.0.1", + port: 4_971, + origin: "http://127.0.0.1:4971", + startedAt: "2026-06-20T00:00:00.000Z", + }; + + yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state }); + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.deepEqual(Option.getOrThrow(restored), state); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("treats a missing runtime state file as absent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState( + path.join(root, "missing.json"), + ); + + assert.isTrue(Option.isNone(restored)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves malformed state decode failures", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "server.json"); + yield* fileSystem.writeFileString(statePath, "{not json"); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.isTrue(Option.isNone(restored)); + assert.equal(logs[0]?.message, `Failed to decode server runtime state at ${statePath}.`); + const error = logs[0]?.annotations.cause; + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "decode"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to decode server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + } + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + + it.effect("preserves runtime state read failures", () => { + const logs: CapturedLog[] = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const statePath = path.join(root, "server.json"); + yield* fileSystem.makeDirectory(statePath); + + const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + + assert.isTrue(Option.isNone(restored)); + assert.equal(logs[0]?.message, `Failed to read server runtime state at ${statePath}.`); + const error = logs[0]?.annotations.cause; + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "read"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to read server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + } + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + + it.effect("preserves runtime state persistence failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-runtime-state-test-", + }); + const blockedDirectory = path.join(root, "not-a-directory"); + const statePath = path.join(blockedDirectory, "server.json"); + yield* fileSystem.writeFileString(blockedDirectory, "blocked"); + + const error = yield* ServerRuntimeState.persistServerRuntimeState({ + path: statePath, + state: { + version: 1, + pid: 123, + port: 4_971, + origin: "http://127.0.0.1:4971", + startedAt: "2026-06-20T00:00:00.000Z", + }, + }).pipe(Effect.flip); + + assert.isTrue(isServerRuntimeStateError(error)); + if (isServerRuntimeStateError(error)) { + assert.equal(error.operation, "persist"); + assert.equal(error.statePath, statePath); + assert.equal(error.message, `Failed to persist server runtime state at ${statePath}.`); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 289bddcb8bbe..329b000369a0 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -18,6 +18,19 @@ export const PersistedServerRuntimeState = Schema.Struct({ }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; +export class ServerRuntimeStateError extends Schema.TaggedErrorClass()( + "ServerRuntimeStateError", + { + operation: Schema.Literals(["persist", "read", "decode", "clear"]), + statePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} server runtime state at ${this.statePath}.`; + } +} + const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( Schema.fromJsonString(PersistedServerRuntimeState), ); @@ -51,27 +64,90 @@ export const persistServerRuntimeState = (input: { writeFileStringAtomically({ filePath: input.path, contents: `${JSON.stringify(input.state)}\n`, - }); + }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "persist", + statePath: input.path, + cause, + }), + ), + ); export const clearPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.remove(path, { force: true }).pipe(Effect.ignore({ log: true })); + yield* fs.remove(path, { force: true }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "clear", + statePath: path, + cause, + }), + ), + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + ), + }), + ); }); export const readPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { + const raw = yield* fs.readFileString(path).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + new ServerRuntimeStateError({ + operation: "read", + statePath: path, + cause, + }), + ), + onSuccess: (contents) => Effect.succeed(Option.some(contents)), + }), + ); + if (Option.isNone(raw)) { return Option.none(); } - const raw = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => "")); - const trimmed = raw.trim(); + const trimmed = raw.value.trim(); if (trimmed.length === 0) { return Option.none(); } - return yield* decodePersistedServerRuntimeState(trimmed).pipe(Effect.option); - }); + return yield* decodePersistedServerRuntimeState(trimmed).pipe( + Effect.map(Option.some), + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "decode", + statePath: path, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + Effect.as(Option.none()), + ), + }), + ); From e042c25f568f297402ddfa76477e29aecddaa85f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:24:38 -0700 Subject: [PATCH 35/80] [codex] Structure rotating log sink errors (#3279) Co-authored-by: codex --- packages/shared/src/logging.test.ts | 151 ++++++++++++++++++++++++++++ packages/shared/src/logging.ts | 96 +++++++++++++++--- 2 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/logging.test.ts diff --git a/packages/shared/src/logging.test.ts b/packages/shared/src/logging.test.ts new file mode 100644 index 000000000000..0e1ea2738bcf --- /dev/null +++ b/packages/shared/src/logging.test.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + RotatingFileSink, + RotatingFileSinkConfigurationError, + RotatingFileSinkError, +} from "./logging.ts"; + +const tempDirectories: string[] = []; + +const makeTempDirectory = (): string => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-")); + tempDirectories.push(directory); + return directory; +}; + +const captureError = (run: () => unknown): unknown => { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error("Expected operation to throw"); +}; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("RotatingFileSink", () => { + it.each([ + { option: "maxBytes" as const, maxBytes: 0, maxFiles: 1 }, + { option: "maxFiles" as const, maxBytes: 1, maxFiles: 0 }, + ])("reports invalid $option configuration structurally", (input) => { + const thrown = captureError( + () => + new RotatingFileSink({ + filePath: "/unused/log.ndjson", + maxBytes: input.maxBytes, + maxFiles: input.maxFiles, + }), + ); + + expect(thrown).toBeInstanceOf(RotatingFileSinkConfigurationError); + expect(thrown).toMatchObject({ + option: input.option, + received: 0, + minimum: 1, + }); + expect((thrown as Error).message).toBe(`${input.option} must be >= 1 (received 0)`); + }); + + it("preserves directory initialization failures", () => { + const directory = makeTempDirectory(); + const parentFile = NodePath.join(directory, "not-a-directory"); + const filePath = NodePath.join(parentFile, "log.ndjson"); + NodeFS.writeFileSync(parentFile, "occupied"); + + const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 })); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "initialize", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); + + it("only treats a missing log file as an empty current size", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "a".repeat(300)); + + const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 })); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "read", filePath }); + expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "ENAMETOOLONG" }); + }); + + it("starts an absent log file at zero bytes", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + const sink = new RotatingFileSink({ filePath, maxBytes: 100, maxFiles: 1 }); + + sink.write("entry"); + + expect(NodeFS.readFileSync(filePath, "utf8")).toBe("entry"); + }); + + it("preserves write failures", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + NodeFS.mkdirSync(filePath); + const sink = new RotatingFileSink({ + filePath, + maxBytes: Number.MAX_SAFE_INTEGER, + maxFiles: 1, + throwOnError: true, + }); + + const thrown = captureError(() => sink.write("entry")); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "write", filePath }); + expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "EISDIR" }); + }); + + it("preserves rotation failures without an artificial write wrapper", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + NodeFS.writeFileSync(filePath, "a"); + NodeFS.mkdirSync(`${filePath}.1`); + const sink = new RotatingFileSink({ + filePath, + maxBytes: 1, + maxFiles: 1, + throwOnError: true, + }); + + const thrown = captureError(() => sink.write("b")); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "rotate", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); + + it("preserves backup pruning failures", () => { + const directory = makeTempDirectory(); + const filePath = NodePath.join(directory, "log.ndjson"); + const overflowBackup = `${filePath}.2`; + NodeFS.mkdirSync(overflowBackup); + NodeFS.writeFileSync(NodePath.join(overflowBackup, "entry"), "occupied"); + + const thrown = captureError( + () => + new RotatingFileSink({ + filePath, + maxBytes: 1, + maxFiles: 1, + throwOnError: true, + }), + ); + + expect(thrown).toBeInstanceOf(RotatingFileSinkError); + expect(thrown).toMatchObject({ operation: "prune", filePath }); + expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error); + }); +}); diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts index e19d5cd0efac..4aa9c7843a61 100644 --- a/packages/shared/src/logging.ts +++ b/packages/shared/src/logging.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; export interface RotatingFileSinkOptions { readonly filePath: string; @@ -9,6 +10,37 @@ export interface RotatingFileSinkOptions { readonly throwOnError?: boolean; } +export class RotatingFileSinkConfigurationError extends Schema.TaggedErrorClass()( + "RotatingFileSinkConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + received: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `${this.option} must be >= ${this.minimum} (received ${this.received})`; + } +} + +export class RotatingFileSinkError extends Schema.TaggedErrorClass()( + "RotatingFileSinkError", + { + operation: Schema.Literals(["initialize", "read", "write", "rotate", "prune"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} rotating log file ${this.filePath}`; + } +} + +const isRotatingFileSinkError = Schema.is(RotatingFileSinkError); + +const isFileNotFoundError = (cause: unknown): cause is NodeJS.ErrnoException => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + export class RotatingFileSink { private readonly filePath: string; private readonly maxBytes: number; @@ -18,10 +50,18 @@ export class RotatingFileSink { constructor(options: RotatingFileSinkOptions) { if (options.maxBytes < 1) { - throw new Error(`maxBytes must be >= 1 (received ${options.maxBytes})`); + throw new RotatingFileSinkConfigurationError({ + option: "maxBytes", + received: options.maxBytes, + minimum: 1, + }); } if (options.maxFiles < 1) { - throw new Error(`maxFiles must be >= 1 (received ${options.maxFiles})`); + throw new RotatingFileSinkConfigurationError({ + option: "maxFiles", + received: options.maxFiles, + minimum: 1, + }); } this.filePath = options.filePath; @@ -29,7 +69,15 @@ export class RotatingFileSink { this.maxFiles = options.maxFiles; this.throwOnError = options.throwOnError ?? false; - NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); + try { + NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); + } catch (cause) { + throw new RotatingFileSinkError({ + operation: "initialize", + filePath: this.filePath, + cause, + }); + } this.pruneOverflowBackups(); this.currentSize = this.readCurrentSize(); } @@ -49,11 +97,18 @@ export class RotatingFileSink { if (this.currentSize > this.maxBytes) { this.rotate(); } - } catch { - this.currentSize = this.readCurrentSize(); + } catch (cause) { + if (isRotatingFileSinkError(cause)) { + throw cause; + } if (this.throwOnError) { - throw new Error(`Failed to write log chunk to ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "write", + filePath: this.filePath, + cause, + }); } + this.currentSize = this.readCurrentSize(); } } @@ -77,11 +132,15 @@ export class RotatingFileSink { } this.currentSize = 0; - } catch { - this.currentSize = this.readCurrentSize(); + } catch (cause) { if (this.throwOnError) { - throw new Error(`Failed to rotate log file ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "rotate", + filePath: this.filePath, + cause, + }); } + this.currentSize = this.readCurrentSize(); } } @@ -95,9 +154,13 @@ export class RotatingFileSink { if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue; NodeFS.rmSync(NodePath.join(dir, entry), { force: true }); } - } catch { + } catch (cause) { if (this.throwOnError) { - throw new Error(`Failed to prune log backups for ${this.filePath}`); + throw new RotatingFileSinkError({ + operation: "prune", + filePath: this.filePath, + cause, + }); } } } @@ -105,8 +168,15 @@ export class RotatingFileSink { private readCurrentSize(): number { try { return NodeFS.statSync(this.filePath).size; - } catch { - return 0; + } catch (cause) { + if (isFileNotFoundError(cause)) { + return 0; + } + throw new RotatingFileSinkError({ + operation: "read", + filePath: this.filePath, + cause, + }); } } From f2cb14e77486174f3e09092e8023b632488006ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:31:33 -0700 Subject: [PATCH 36/80] [codex] Structure mobile relay token-store failures (#3309) Co-authored-by: codex --- .../cloud/managedRelayTokenStore.test.ts | 36 +++++++- .../features/cloud/managedRelayTokenStore.ts | 85 ++++++++++++------- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts index 616fc1add7cc..9642e5f63ae2 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts @@ -1,5 +1,7 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as SecureStore from "expo-secure-store"; import { vi } from "vite-plus/test"; const secureStore = vi.hoisted(() => new Map()); @@ -16,7 +18,10 @@ vi.mock("expo-secure-store", () => ({ }), })); -import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; +import { + ManagedRelayTokenStoreError, + managedRelayAccessTokenStore, +} from "./managedRelayTokenStore"; it.effect("round-trips and clears persisted managed relay access tokens", () => Effect.gen(function* () { @@ -49,3 +54,32 @@ it.effect("falls back to an empty cache when persisted data is invalid", () => expect(yield* managedRelayAccessTokenStore.load).toEqual([]); }), ); + +it.effect("logs structured storage failures before falling back to an empty cache", () => { + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const cause = new Error("secure store unavailable"); + vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(cause); + + return Effect.gen(function* () { + expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + + const message = messages.find( + (candidate) => + Array.isArray(candidate) && candidate[0] === "Managed relay token store operation failed.", + ); + expect(message).toBeDefined(); + const context = (message as ReadonlyArray)[1] as { + readonly cause: ManagedRelayTokenStoreError; + }; + expect(context.cause).toBeInstanceOf(ManagedRelayTokenStoreError); + expect(context.cause).toMatchObject({ + operation: "read", + storageKey: "t3code.cloud.relay-access-tokens", + cause, + }); + expect(context.cause.message).not.toContain(cause.message); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); +}); diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts index 460c71c1fa79..0730f277f3c2 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -1,5 +1,4 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; @@ -31,36 +30,50 @@ const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect( ); const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema); -export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{ - readonly message: string; - readonly cause: unknown; -}> {} - -const storeError = - (message: string) => - (cause: unknown): ManagedRelayTokenStoreError => - new ManagedRelayTokenStoreError({ message, cause }); +export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass()( + "ManagedRelayTokenStoreError", + { + operation: Schema.Literals(["read", "decode", "encode", "write", "clear"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Managed relay token store operation "${this.operation}" failed for key "${this.storageKey}".`; + } +} -function logStoreFailure(operation: string) { - return (error: ManagedRelayTokenStoreError) => - Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe( - Effect.annotateLogs({ - errorTag: error._tag, - message: error.message, - }), - ); +function logStoreFailure(error: ManagedRelayTokenStoreError) { + return Effect.logWarning("Managed relay token store operation failed.", { + errorTag: error._tag, + operation: error.operation, + storageKey: error.storageKey, + cause: error, + }); } const loadManagedRelayAccessTokens = Effect.tryPromise({ try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), - catch: storeError("Could not read persisted relay access tokens."), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "read", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), }).pipe( Effect.flatMap((encoded) => encoded === null ? Effect.succeed>([]) : decodeManagedRelayAccessTokenCache(encoded).pipe( Effect.map((cache) => cache.entries), - Effect.mapError(storeError("Persisted relay access tokens are invalid.")), + Effect.mapError( + (cause) => + new ManagedRelayTokenStoreError({ + operation: "decode", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), + ), ), ), ); @@ -72,34 +85,48 @@ const saveManagedRelayAccessTokens = ( version: MANAGED_RELAY_TOKEN_CACHE_VERSION, entries, }).pipe( - Effect.mapError(storeError("Could not encode relay access tokens.")), + Effect.mapError( + (cause) => + new ManagedRelayTokenStoreError({ + operation: "encode", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), + ), Effect.flatMap((encoded) => Effect.tryPromise({ try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded), - catch: storeError("Could not persist relay access tokens."), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "write", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), }), ), ); const clearManagedRelayAccessTokens = Effect.tryPromise({ try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), - catch: storeError("Could not clear persisted relay access tokens."), + catch: (cause) => + new ManagedRelayTokenStoreError({ + operation: "clear", + storageKey: MANAGED_RELAY_TOKEN_CACHE_KEY, + cause, + }), }); export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: loadManagedRelayAccessTokens.pipe( - Effect.tapError(logStoreFailure("load")), + Effect.tapError(logStoreFailure), Effect.orElseSucceed(() => []), Effect.withSpan("mobile.managedRelayTokenStore.load"), ), save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) => - saveManagedRelayAccessTokens(entries).pipe( - Effect.tapError(logStoreFailure("save")), - Effect.ignore, - ), + saveManagedRelayAccessTokens(entries).pipe(Effect.tapError(logStoreFailure), Effect.ignore), ), clear: clearManagedRelayAccessTokens.pipe( - Effect.tapError(logStoreFailure("clear")), + Effect.tapError(logStoreFailure), Effect.ignore, Effect.withSpan("mobile.managedRelayTokenStore.clear"), ), From 674590e0098e397c44c1be50adedc875635209a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:47:58 -0700 Subject: [PATCH 37/80] [codex] Fix terminal cwd error test construction (#3440) Co-authored-by: codex --- apps/server/src/project/ProjectSetupScriptRunner.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index e8d771b74df3..fdf95df0b996 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -153,9 +153,8 @@ describe("ProjectSetupScriptRunner", () => { it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { const rootCause = new Error("stat failed"); - const terminalError = new TerminalManager.TerminalCwdError({ + const terminalError = new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", - reason: "statFailed", cause: rootCause, }); const project = makeProject([ From ad2cb1dd57dbdb028f6544ad780df4616ed41437 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:55:38 -0700 Subject: [PATCH 38/80] [codex] Diagnose desktop client settings read failures (#3432) Co-authored-by: codex --- .../DesktopClientSettings.diagnostics.test.ts | 129 ++++++++++++++++++ .../src/settings/DesktopClientSettings.ts | 19 ++- 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts new file mode 100644 index 000000000000..5034df44cf70 --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; + +interface LogRecord { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const baseDir = "/virtual-home"; + +function makeLayer(fileSystemLayer: Layer.Layer) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopClientSettings.layer.pipe( + Layer.provideMerge(Layer.mergeAll(environmentLayer, NodeServices.layer, fileSystemLayer)), + ); +} + +const readWithLogs = (fileSystemLayer: Layer.Layer) => { + const records: Array = []; + const logger = Logger.make(({ fiber, message }) => { + records.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + return { + result: yield* settings.get, + settingsPath: environment.clientSettingsPath, + records, + }; + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer(fileSystemLayer), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); +}; + +describe("DesktopClientSettings diagnostics", () => { + it.effect("treats a missing settings file as expected without warning", () => + Effect.gen(function* () { + const result = yield* readWithLogs(FileSystem.layerNoop({})); + + assert.isTrue(Option.isNone(result.result)); + assert.deepEqual(result.records, []); + }), + ); + + it.effect("logs non-missing filesystem failures with the settings path", () => { + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: `${baseDir}/userdata/client-settings.json`, + }); + + return Effect.gen(function* () { + const result = yield* readWithLogs( + FileSystem.layerNoop({ + readFileString: () => Effect.fail(permissionError), + }), + ); + + assert.isTrue(Option.isNone(result.result)); + assert.equal(result.records.length, 1); + assert.deepEqual(result.records[0]?.message, [ + "Could not read desktop client settings.", + permissionError, + ]); + assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); + }); + }); + + it.effect("logs malformed settings documents with the settings path", () => + Effect.gen(function* () { + const result = yield* readWithLogs( + FileSystem.layerNoop({ + readFileString: () => Effect.succeed("{not-json"), + }), + ); + + assert.isTrue(Option.isNone(result.result)); + assert.equal(result.records.length, 1); + const message = result.records[0]?.message; + if (!Array.isArray(message)) { + return assert.fail("expected structured warning arguments"); + } + assert.equal(message[0], "Could not decode desktop client settings."); + const schemaError = message[1]; + if (schemaError === null || typeof schemaError !== "object") { + return assert.fail("expected the schema error in the warning"); + } + assert.equal("_tag" in schemaError ? schemaError._tag : undefined, "SchemaError"); + assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); + }), + ); +}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index d08184f4ab76..4ff091e27a27 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -67,14 +67,29 @@ const readClientSettings = ( settingsPath: string, ): Effect.Effect> => fileSystem.readFileString(settingsPath).pipe( - Effect.option, + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.logWarning("Could not read desktop client settings.", cause).pipe( + Effect.annotateLogs({ settingsPath }), + Effect.as(Option.none()), + ), + }), Effect.flatMap( Option.match({ onNone: () => Effect.succeed(Option.none()), onSome: (raw) => decodeClientSettingsJson(raw).pipe( Effect.map((settings) => Option.some(settings)), - Effect.orElseSucceed(() => Option.none()), + Effect.catchTags({ + SchemaError: (cause) => + Effect.logWarning("Could not decode desktop client settings.", cause).pipe( + Effect.annotateLogs({ settingsPath }), + Effect.as(Option.none()), + ), + }), ), }), ), From 60c9ca0e33dcb30c9c8348b37615a10615fc4e5f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:55:44 -0700 Subject: [PATCH 39/80] [codex] Preserve checkpoint repository detection failures (#3360) Co-authored-by: codex --- .../src/checkpointing/CheckpointStore.test.ts | 21 +++++++++++++++++++ .../src/checkpointing/CheckpointStore.ts | 7 +++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 5a60012108b8..bf332d20d0da 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -93,6 +93,27 @@ function buildLargeText(lineCount = 5_000): string { } it.layer(TestLayer)("CheckpointStore.layer", (it) => { + describe("isGitRepository", () => { + it.effect("returns false when no Git repository is detected", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.isGitRepository(tmp)).toBe(false); + }), + ); + + it.effect("returns true when a Git repository is detected", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + + expect(yield* checkpointStore.isGitRepository(tmp)).toBe(true); + }), + ); + }); + describe("diffCheckpoints", () => { it.effect("returns full oversized checkpoint diffs without truncation", () => Effect.gen(function* () { diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index ed47d5f117f5..f13aa4572c17 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -115,10 +115,9 @@ export const make = Effect.gen(function* () { }); const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => - vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( - Effect.map(() => true), - Effect.orElseSucceed(() => false), - ); + vcsRegistry + .detect({ cwd, requestedKind: "git" }) + .pipe(Effect.map((repository) => repository !== null)); const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( "captureCheckpoint", From 6ff6c13fc8bff69e2967a4f768a076bbb1b88aa5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:55:50 -0700 Subject: [PATCH 40/80] [codex] structure desktop backend process errors (#3254) Co-authored-by: codex --- .../src/backend/DesktopBackendManager.test.ts | 173 +++++++++++++++- .../src/backend/DesktopBackendManager.ts | 188 ++++++++++++------ 2 files changed, 301 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 4a88be8838ae..0c083889f295 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -3,12 +3,15 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -28,6 +31,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); +const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", @@ -57,7 +61,7 @@ const configWithObservability: DesktopBackendBootstrapValue = { function makeProcess(options?: { readonly stdout?: Stream.Stream; readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; + readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; }): ChildProcessSpawner.ChildProcessHandle { return ChildProcessSpawner.makeHandle({ @@ -145,6 +149,23 @@ function makeManagerLayer(input: { } describe("DesktopBackendManager", () => { + it("preserves the complete restart cause and schedule context", () => { + const cause = Cause.combine( + Cause.fail(new Error("start failed")), + Cause.die(new Error("restart defect")), + ); + const error = new DesktopBackendManager.DesktopBackendRestartError({ + reason: "backend exited with code 1", + delayMs: 500, + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.reason, "backend exited with code 1"); + assert.equal(error.delayMs, 500); + assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay."); + }); + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => Effect.gen(function* () { let spawnedCommand: ChildProcess.Command | undefined; @@ -218,6 +239,156 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("preserves the readiness timeout cause and process context", () => + Effect.gen(function* () { + const requested = yield* Deferred.make(); + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer((request) => + Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), + ), + ); + + yield* Effect.gen(function* () { + const readiness = yield* DesktopBackendManager.waitForHttpReady({ + executablePath: baseConfig.executablePath, + entryPath: baseConfig.entryPath, + cwd: baseConfig.cwd, + httpBaseUrl: baseConfig.httpBaseUrl, + timeout: Duration.millis(50), + }).pipe(Effect.flip, Effect.forkChild); + + const request = yield* Deferred.await(requested); + assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); + + yield* TestClock.adjust(Duration.millis(50)); + const error = yield* Fiber.join(readiness); + + assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); + assert.equal(error.timeoutMs, 50); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal( + error.message, + "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", + ); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("reports bootstrap encoding failures with stable process context", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + const error = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + bootstrap: { + ...baseConfig.bootstrap, + port: 0, + }, + }).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessBootstrapEncodeError") { + return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.isDefined(error.cause); + assert.equal( + error.message, + "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", + ); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves spawn failures without deriving their message from the cause", () => + Effect.gen(function* () { + const spawnCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: baseConfig.executablePath, + description: "low-level detail that must not become the public message", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(spawnCause)), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessSpawnError") { + return assert.fail(`Expected backend spawn error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, spawnCause); + assert.equal( + error.message, + "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", + ); + assert.notInclude(error.message, spawnCause.message); + assert.isTrue(isBackendProcessError(error)); + }), + ); + + it.effect("preserves exit-status failures without copying their detail into the message", () => + Effect.gen(function* () { + const exitCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "exitCode", + description: "exit-status-secret-sentinel", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.fail(exitCause), + }), + ), + ), + ); + const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( + Effect.flip, + Effect.scoped, + Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), + ); + + if (error._tag !== "BackendProcessExitStatusError") { + return assert.fail(`Expected backend exit-status error, received ${error._tag}`); + } + assert.equal(error.pid, 123); + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.strictEqual(error.cause, exitCause); + assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); + assert.notInclude(error.message, "exit-status-secret-sentinel"); + assert.isTrue(isBackendProcessError(error)); + }), + ); + it.effect("retries HTTP readiness before reporting the backend ready", () => Effect.gen(function* () { const requestUrls: Array = []; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index bc47cab37d75..8a40c9d21532 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -9,7 +9,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -43,64 +42,107 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; -export interface DesktopBackendStartConfig { +export interface BackendProcessContext { readonly executablePath: string; readonly entryPath: string; readonly cwd: string; + readonly httpBaseUrl: URL; +} + +export interface DesktopBackendStartConfig extends BackendProcessContext { readonly env: Record; readonly bootstrap: DesktopBackendBootstrapValue; - readonly httpBaseUrl: URL; readonly captureOutput: boolean; } interface BackendProcessExit { readonly code: Option.Option; readonly reason: string; - readonly result: Result.Result; } -export class BackendTimeoutError extends Schema.TaggedErrorClass()( - "BackendTimeoutError", +const backendProcessContextSchema = { + executablePath: Schema.String, + entryPath: Schema.String, + cwd: Schema.String, + httpBaseUrl: Schema.URL, +}; + +export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass()( + "BackendReadinessTimeoutError", { - url: Schema.URL, + ...backendProcessContextSchema, + readinessUrl: Schema.URL, + timeoutMs: Schema.Number, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Timed out waiting for backend readiness at ${this.url.href}.`; + return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; } } -class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( +export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( "BackendProcessBootstrapEncodeError", { - detail: Schema.String, + ...backendProcessContextSchema, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to encode desktop backend bootstrap payload: ${this.detail}`; + return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; } } -class BackendProcessSpawnError extends Schema.TaggedErrorClass()( +export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( "BackendProcessSpawnError", { - detail: Schema.String, + ...backendProcessContextSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; + } +} + +export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( + "BackendProcessExitStatusError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the exit status of desktop backend process ${this.pid}.`; + } +} + +export class DesktopBackendRestartError extends Schema.TaggedErrorClass()( + "DesktopBackendRestartError", + { + reason: Schema.String, + delayMs: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to spawn desktop backend process: ${this.detail}`; + return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`; } } -type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; +export const BackendProcessError = Schema.Union([ + BackendProcessBootstrapEncodeError, + BackendProcessSpawnError, + BackendProcessExitStatusError, +]); +export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly readinessTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onReady?: () => Effect.Effect; - readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, @@ -183,11 +225,10 @@ const closeRun = ( ).pipe(Effect.ignore); }; -const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( - baseUrl: URL, - timeout: Duration.Duration, -): Effect.fn.Return { - const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); +export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + options: BackendProcessContext & { readonly timeout: Duration.Duration }, +): Effect.fn.Return { + const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); const client = (yield* HttpClient.HttpClient).pipe( HttpClient.filterStatusOk, HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), @@ -196,29 +237,22 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu yield* client.get(readinessUrl).pipe( Effect.asVoid, - Effect.timeout(timeout), - Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), + Effect.timeout(options.timeout), + Effect.mapError( + (cause) => + new BackendReadinessTimeoutError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + readinessUrl, + timeoutMs: Duration.toMillis(options.timeout), + cause, + }), + ), ); }); -function describeProcessExit( - result: Result.Result, -): BackendProcessExit { - if (Result.isSuccess(result)) { - return { - code: Option.some(result.success), - reason: `code=${result.success}`, - result, - }; - } - - return { - code: Option.none(), - reason: result.failure.message, - result, - }; -} - function drainBackendOutput( streamName: BackendProcessOutputStream, stream: Stream.Stream, @@ -232,7 +266,7 @@ function drainBackendOutput( const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); -const runBackendProcess = Effect.fn("runBackendProcess")(function* ( +export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -240,7 +274,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError( (cause) => new BackendProcessBootstrapEncodeError({ - detail: cause.message, + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, cause, }), ), @@ -273,7 +310,10 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.mapError( (cause) => new BackendProcessSpawnError({ - detail: cause.message, + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, cause, }), ), @@ -284,16 +324,37 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); } - yield* waitForHttpReady( - options.httpBaseUrl, - options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - ).pipe( + yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, + }), Effect.forkScoped, ); - return describeProcessExit(yield* Effect.result(handle.exitCode)); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError( + (cause) => + new BackendProcessExitStatusError({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + cause, + }), + ), + ); + return { + code: Option.some(exitCode), + reason: `code=${exitCode}`, + } satisfies BackendProcessExit; }); export const make = Effect.gen(function* () { @@ -351,7 +412,7 @@ export const make = Effect.gen(function* () { const config = yield* configuration.resolve.pipe( Effect.tapError((error) => logBackendManagerError("failed to generate desktop backend configuration", { - cause: error.message, + cause: error, }), ), Effect.option, @@ -489,14 +550,14 @@ export const make = Effect.gen(function* () { yield* desktopWindow.handleBackendReady.pipe( Effect.catch((error) => logBackendManagerError("failed to open main window after backend readiness", { - message: error.message, + cause: error, }), ), ); }), onReadinessFailure: (error) => logBackendManagerWarning("backend readiness check failed during bootstrap", { - error: error.message, + error, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( @@ -504,7 +565,10 @@ export const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message), + onFailure: (error) => + logBackendManagerError(error.message, { error }).pipe( + Effect.andThen(finalizeRun(error.message)), + ), onSuccess: (exit) => finalizeRun(exit.reason), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), @@ -559,11 +623,17 @@ export const make = Effect.gen(function* () { }), ), Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), - Effect.catchCause((cause) => - logBackendManagerError("desktop backend restart fiber failed", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopBackendRestartError({ + reason, + delayMs: Duration.toMillis(delay), + cause, + }); + return logBackendManagerError(error.message, { error }); + }), ), parentScope, ); From a26f0dc89fd46f88529823511cdd068c9cd2c151 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 15:56:46 -0700 Subject: [PATCH 41/80] [codex] structure source-control repository failures (#3336) Co-authored-by: codex --- .../SourceControlRepositoryService.test.ts | 87 ++++++++++++++++++- .../SourceControlRepositoryService.ts | 56 ++++-------- 2 files changed, 98 insertions(+), 45 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index c792480b7fc2..861da9a10e05 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,11 +1,13 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; +import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -54,8 +56,9 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { function makeLayer(input: { readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; readonly git?: Partial; + readonly fileSystem?: FileSystem.FileSystem; }) { - return SourceControlRepositoryService.layer.pipe( + const serviceLayer = SourceControlRepositoryService.layer.pipe( Layer.provide( Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ get: () => Effect.succeed(input.provider ?? makeProvider()), @@ -75,9 +78,20 @@ function makeLayer(input: { ...input.git, }), ), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })), - Layer.provideMerge(NodeServices.layer), + Layer.provide( + ServerConfig.layerTest( + process.cwd(), + input.fileSystem ? "/tmp/t3-source-control-repos" : { prefix: "t3-source-control-repos-" }, + ), + ), ); + + return input.fileSystem + ? serviceLayer.pipe( + Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)), + Layer.provideMerge(NodePath.layer), + ) + : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer)); } it.effect("looks up repositories through the requested provider without search", () => { @@ -103,6 +117,39 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("preserves provider failures without deriving the repository message from them", () => { + const providerCause = new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: "/workspace", + repository: "octocat/t3code", + detail: "credential token abc123 was rejected", + }); + const provider = makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(providerCause), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ + provider: "github", + repository: "octocat/t3code", + cwd: "/workspace", + }), + ); + + assert.strictEqual(error.provider, "github"); + assert.strictEqual(error.operation, "lookupRepository"); + assert.strictEqual(error.detail, "The source control operation could not be completed."); + assert.strictEqual( + error.message, + "Source control repository operation lookupRepository failed for github: The source control operation could not be completed.", + ); + assert.strictEqual(error.cause, providerCause); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -148,6 +195,38 @@ it.effect("clones a looked-up repository into the requested destination", () => }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("preserves destination probe failures instead of treating them as missing paths", () => { + const fileSystemCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: "/restricted/t3code", + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.sshUrl, + destinationPath: "/restricted/t3code", + }), + ); + + assert.strictEqual(error.provider, "unknown"); + assert.strictEqual(error.operation, "cloneRepository"); + assert.strictEqual(error.cause, fileSystemCause); + }).pipe( + Effect.provide( + makeLayer({ + fileSystem: FileSystem.makeNoop({ + exists: () => Effect.fail(fileSystemCause), + makeDirectory: () => Effect.void, + }), + }), + ), + ); +}); + it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index ff88a4c31469..1b46369e25c4 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -39,41 +39,14 @@ export class SourceControlRepositoryService extends Context.Service< } >()("t3/sourceControl/SourceControlRepositoryService") {} -function detailFromUnknown(cause: unknown): string { - if (typeof cause === "object" && cause !== null) { - if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) { - return cause.detail; - } - if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) { - return cause.message; - } - } - - return "An unexpected source control error occurred."; -} - -function repositoryError(input: { - readonly operation: string; - readonly provider: SourceControlProviderKind; - readonly detail: string; - readonly cause?: unknown; -}): SourceControlRepositoryError { - return new SourceControlRepositoryError({ - provider: input.provider, - operation: input.operation, - detail: input.detail, - ...(input.cause === undefined ? {} : { cause: input.cause }), - }); -} - function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { return Effect.mapError((cause: unknown) => isSourceControlRepositoryError(cause) ? cause - : repositoryError({ + : new SourceControlRepositoryError({ operation, provider, - detail: detailFromUnknown(cause), + detail: "The source control operation could not be completed.", cause, }), ); @@ -130,7 +103,7 @@ export const make = Effect.gen(function* () { } return Effect.fail( - repositoryError({ + new SourceControlRepositoryError({ operation: input.operation, provider: input.provider, detail: "Choose a source control provider before continuing.", @@ -157,7 +130,7 @@ export const make = Effect.gen(function* () { function* (destinationPath: string) { const trimmed = destinationPath.trim(); if (trimmed.length === 0) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider: "unknown", detail: "Choose a destination path before cloning.", @@ -171,21 +144,22 @@ export const make = Effect.gen(function* () { const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( function* (destinationPath: string) { const normalizedDestination = yield* normalizeDestinationPath(destinationPath); - if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) { + if (yield* fileSystem.exists(normalizedDestination)) { const entries = yield* fileSystem .readDirectory(normalizedDestination, { recursive: false }) .pipe( - Effect.mapError((cause) => - repositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Destination path already exists and is not a directory.", - cause, - }), + Effect.mapError( + (cause) => + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not a directory.", + cause, + }), ), ); if (entries.length > 0) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider: "unknown", detail: "Destination path already exists and is not empty.", @@ -222,7 +196,7 @@ export const make = Effect.gen(function* () { } if (!remoteUrl) { - return yield* repositoryError({ + return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider, detail: "Enter a repository path or clone URL before cloning.", From 57d25c934b74986423896a7dc433595ed09b9c8d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:15:16 -0700 Subject: [PATCH 42/80] [codex] Structure persistence error correlation (#3439) Co-authored-by: codex --- .../src/persistence/AuthPairingLinks.ts | 84 +++++- apps/server/src/persistence/AuthSessions.ts | 70 ++++- apps/server/src/persistence/Errors.ts | 17 +- .../src/persistence/ProviderSessionRuntime.ts | 59 ++-- .../RepositoryErrorCorrelation.test.ts | 253 ++++++++++++++++++ 5 files changed, 451 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/persistence/RepositoryErrorCorrelation.test.ts diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index c29b023d1d88..e54c977e7ab7 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -11,6 +11,7 @@ import { AuthEnvironmentScopes } from "@t3tools/contracts"; import { type AuthPairingLinkRepositoryError, PersistenceDecodeError, + type PersistenceErrorCorrelation, PersistenceSqlError, } from "./Errors.ts"; @@ -66,6 +67,22 @@ export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ }); export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; +const AuthPairingLinkRawDbRow = Schema.Struct({ + id: Schema.String, + credential: Schema.Unknown, + method: Schema.Unknown, + scopes: Schema.Unknown, + subject: Schema.Unknown, + label: Schema.Unknown, + proofKeyThumbprint: Schema.Unknown, + createdAt: Schema.Unknown, + expiresAt: Schema.Unknown, + consumedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord); + export class AuthPairingLinkRepository extends Context.Service< AuthPairingLinkRepository, { @@ -87,11 +104,19 @@ export class AuthPairingLinkRepository extends Context.Service< } >()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {} -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthPairingLinkRepositoryError => Schema.isSchemaError(cause) - ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) - : new PersistenceSqlError({ operation: sqlOperation, cause }); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } export const make = Effect.gen(function* () { @@ -132,7 +157,7 @@ export const make = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => sql` UPDATE auth_pairing_links @@ -162,7 +187,7 @@ export const make = Effect.gen(function* () { const listActivePairingLinkRows = SqlSchema.findAll({ Request: ListActiveAuthPairingLinksInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ now }) => sql` SELECT @@ -201,7 +226,7 @@ export const make = Effect.gen(function* () { const getPairingLinkRowByCredential = SqlSchema.findOneOption({ Request: GetAuthPairingLinkByCredentialInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential }) => sql` SELECT @@ -227,6 +252,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.create:query", "AuthPairingLinkRepository.create:encodeRequest", + { pairingLinkId: input.id }, ), ), ); @@ -239,6 +265,22 @@ export const make = Effect.gen(function* () { "AuthPairingLinkRepository.consumeAvailable:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) => @@ -249,6 +291,19 @@ export const make = Effect.gen(function* () { "AuthPairingLinkRepository.listActive:decodeRows", ), ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.listActive:decodeRows", + cause, + { pairingLinkId: row.id }, + ), + ), + ), + ), + ), ); const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) => @@ -257,6 +312,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.revoke:query", "AuthPairingLinkRepository.revoke:decodeRows", + { pairingLinkId: input.id }, ), ), Effect.map((rows) => rows.length > 0), @@ -270,6 +326,22 @@ export const make = Effect.gen(function* () { "AuthPairingLinkRepository.getByCredential:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.getByCredential:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); return { diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 17f76042d0ab..545688e38228 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -16,6 +16,7 @@ import { import { type AuthSessionRepositoryError, PersistenceDecodeError, + type PersistenceErrorCorrelation, PersistenceSqlError, } from "./Errors.ts"; @@ -122,6 +123,25 @@ const AuthSessionDbRow = Schema.Struct({ revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); +const AuthSessionRawDbRow = Schema.Struct({ + sessionId: Schema.String, + subject: Schema.Unknown, + scopes: Schema.Unknown, + method: Schema.Unknown, + clientLabel: Schema.Unknown, + clientIpAddress: Schema.Unknown, + clientUserAgent: Schema.Unknown, + clientDeviceType: Schema.Unknown, + clientOs: Schema.Unknown, + clientBrowser: Schema.Unknown, + issuedAt: Schema.Unknown, + expiresAt: Schema.Unknown, + lastConnectedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); + function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { return { sessionId: row.sessionId, @@ -143,11 +163,19 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco }; } -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthSessionRepositoryError => Schema.isSchemaError(cause) - ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) - : new PersistenceSqlError({ operation: sqlOperation, cause }); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } export const make = Effect.gen(function* () { @@ -192,7 +220,7 @@ export const make = Effect.gen(function* () { const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ sessionId }) => sql` SELECT @@ -217,7 +245,7 @@ export const make = Effect.gen(function* () { const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ now }) => sql` SELECT @@ -285,6 +313,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.create:query", "AuthSessionRepository.create:encodeRequest", + { sessionId: input.sessionId }, ), ), ); @@ -295,12 +324,23 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.getById:query", "AuthSessionRepository.getById:decodeRow", + { sessionId: input.sessionId }, ), ), Effect.flatMap((rowOption) => Option.match(rowOption, { onNone: () => Effect.succeed(Option.none()), - onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), + onSome: (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.getById:decodeRow", + cause, + { sessionId: input.sessionId }, + ), + ), + Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))), + ), }), ), ); @@ -313,7 +353,20 @@ export const make = Effect.gen(function* () { "AuthSessionRepository.listActive:decodeRows", ), ), - Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.listActive:decodeRows", + cause, + { sessionId: row.sessionId }, + ), + ), + Effect.map(toAuthSessionRecord), + ), + ), + ), ); const revoke: AuthSessionRepository["Service"]["revoke"] = (input) => @@ -322,6 +375,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows", + { sessionId: input.sessionId }, ), ), Effect.map((rows) => rows.length > 0), @@ -333,6 +387,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows", + { currentSessionId: input.currentSessionId }, ), ), Effect.map((rows) => rows.map((row) => row.sessionId)), @@ -344,6 +399,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest", + { sessionId: input.sessionId }, ), ), ); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index e7d081c8f728..03edaec77d63 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -19,11 +19,20 @@ function summarizeSchemaIssue(issue: SchemaIssue.Issue): string { // Core Persistence Errors // =============================== +export const PersistenceErrorCorrelation = Schema.Union([ + Schema.Struct({ sessionId: Schema.String }), + Schema.Struct({ currentSessionId: Schema.String }), + Schema.Struct({ pairingLinkId: Schema.String }), + Schema.Struct({ threadId: Schema.String }), +]); +export type PersistenceErrorCorrelation = typeof PersistenceErrorCorrelation.Type; + export class PersistenceSqlError extends Schema.TaggedErrorClass()( "PersistenceSqlError", { operation: Schema.String, detail: Schema.optional(Schema.String), + correlation: Schema.optional(PersistenceErrorCorrelation), cause: Schema.optional(Schema.Defect()), }, ) { @@ -39,13 +48,19 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass Schema.isSchemaError(cause) - ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) - : new PersistenceSqlError({ operation: sqlOperation, cause }); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } export const make = Effect.gen(function* () { @@ -165,7 +186,7 @@ export const make = Effect.gen(function* () { const getRuntimeRowByThreadId = SqlSchema.findOneOption({ Request: GetRuntimeRequestSchema, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: ({ threadId }) => sql` SELECT @@ -185,7 +206,7 @@ export const make = Effect.gen(function* () { const listRuntimeRows = SqlSchema.findAll({ Request: Schema.Void, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: () => sql` SELECT @@ -218,6 +239,7 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", "ProviderSessionRuntimeRepository.upsert:encodeRequest", + { threadId: runtime.threadId }, ), ), ); @@ -228,17 +250,19 @@ export const make = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.getByThreadId:query", "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + { threadId: input.threadId }, ), ), Effect.flatMap((runtimeRowOption) => Option.match(runtimeRowOption, { onNone: () => Effect.succeed(Option.none()), onSome: (row) => - decodeRuntime(row).pipe( + decodeRuntimeRow(row).pipe( Effect.mapError((cause) => PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", cause, + { threadId: input.threadId }, ), ), Effect.map((runtime) => Option.some(runtime)), @@ -256,18 +280,16 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeRuntime(row).pipe( - Effect.mapError((cause) => - PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.list:rowToRuntime", - cause, - ), + Effect.forEach(rows, (row) => + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, ), ), - { concurrency: "unbounded" }, + ), ), ), ); @@ -280,6 +302,7 @@ export const make = Effect.gen(function* () { (cause) => new PersistenceSqlError({ operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + correlation: { threadId: input.threadId }, cause, }), ), diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts new file mode 100644 index 000000000000..f7425200fd1d --- /dev/null +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -0,0 +1,253 @@ +import { AuthSessionId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as AuthPairingLinks from "./AuthPairingLinks.ts"; +import * as AuthSessions from "./AuthSessions.ts"; +import * as PersistenceErrors from "./Errors.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts"; + +const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); +const expiresAt = DateTime.makeUnsafe("2027-06-20T00:00:00.000Z"); +const now = DateTime.makeUnsafe("2026-06-21T00:00:00.000Z"); +const scopes: ReadonlyArray = ["access:read"]; + +const authSessionLayer = AuthSessions.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const authPairingLinkLayer = AuthPairingLinks.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); +const providerSessionRuntimeLayer = ProviderSessionRuntime.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); + +describe("persistence error correlation", () => { + it.effect("correlates auth session SQL and row-decode failures without sensitive fields", () => + Effect.gen(function* () { + const sessions = yield* AuthSessions.AuthSessionRepository; + const sql = yield* SqlClient.SqlClient; + const sessionId = AuthSessionId.make("session-correlation"); + const currentSessionId = AuthSessionId.make("current-session-correlation"); + const subject = "session-subject-secret-sentinel"; + + yield* sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }); + yield* sql` + UPDATE auth_sessions + SET scopes = ${"session-scopes-secret-sentinel"} + WHERE session_id = ${sessionId} + `; + + const decodeError = yield* Effect.flip(sessions.listActive({ now })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { sessionId }); + assert.equal( + decodeError.message, + `Decode error in AuthSessionRepository.listActive:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, "session-scopes-secret-sentinel"); + assert.notInclude(decodeError.message, subject); + + yield* sql`DROP TABLE auth_sessions`; + const createError = yield* Effect.flip( + sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { sessionId }); + assert.equal(createError.message, "SQL error in AuthSessionRepository.create:query"); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeOtherError = yield* Effect.flip( + sessions.revokeAllExcept({ currentSessionId, revokedAt: now }), + ); + assert.instanceOf(revokeOtherError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeOtherError.correlation, { currentSessionId }); + assert.equal( + revokeOtherError.message, + "SQL error in AuthSessionRepository.revokeAllExcept:query", + ); + assert.notInclude(revokeOtherError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authSessionLayer)), + ); + + it.effect("correlates pairing-link create and revoke failures by id only", () => + Effect.gen(function* () { + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; + const sql = yield* SqlClient.SqlClient; + const id = "pairing-link-correlation"; + const credential = "pairing-credential-secret-sentinel"; + const subject = "pairing-subject-secret-sentinel"; + const scopesPayload = "pairing-scopes-secret-sentinel"; + + yield* sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + scopes, + subject, + label, + proof_key_thumbprint, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${id}, + ${credential}, + ${"one-time-token"}, + ${scopesPayload}, + ${subject}, + NULL, + NULL, + ${DateTime.formatIso(issuedAt)}, + ${DateTime.formatIso(expiresAt)}, + NULL, + NULL + ) + `; + + const decodeError = yield* Effect.flip(pairingLinks.getByCredential({ credential })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { pairingLinkId: id }); + assert.equal( + decodeError.message, + `Decode error in AuthPairingLinkRepository.getByCredential:decodeRow: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, credential); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, scopesPayload); + assert.notInclude(decodeError.message, DateTime.formatIso(issuedAt)); + + yield* sql`DROP TABLE auth_pairing_links`; + const createError = yield* Effect.flip( + pairingLinks.create({ + id, + credential, + method: "one-time-token", + scopes, + subject, + label: null, + proofKeyThumbprint: null, + createdAt: issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { pairingLinkId: id }); + assert.notInclude(createError.message, credential); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeError = yield* Effect.flip(pairingLinks.revoke({ id, revokedAt: now })); + assert.instanceOf(revokeError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeError.correlation, { pairingLinkId: id }); + assert.notInclude(revokeError.message, credential); + assert.notInclude(revokeError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authPairingLinkLayer)), + ); + + it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + Effect.gen(function* () { + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-correlation"); + const runtimePayload = "runtime-payload-secret-sentinel"; + const lastSeenAt = "2026-06-20T00:00:00.000Z"; + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${threadId}, + ${"codex"}, + NULL, + ${"codex"}, + ${"invalid-runtime-mode"}, + ${"running"}, + ${lastSeenAt}, + NULL, + ${`{"secret":"${runtimePayload}"}`} + ) + `; + + const decodeError = yield* Effect.flip(runtimes.list()); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { threadId }); + assert.equal( + decodeError.message, + `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, runtimePayload); + assert.notInclude(decodeError.message, runtimePayload); + assert.notInclude(decodeError.message, lastSeenAt); + + yield* sql`DROP TABLE provider_session_runtime`; + const sqlFailure = yield* Effect.flip( + runtimes.upsert({ + threadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: { secret: runtimePayload }, + }), + ); + assert.instanceOf(sqlFailure, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(sqlFailure.correlation, { threadId }); + assert.equal( + sqlFailure.message, + "SQL error in ProviderSessionRuntimeRepository.upsert:query", + ); + assert.notInclude(sqlFailure.message, runtimePayload); + assert.notInclude(sqlFailure.message, lastSeenAt); + }).pipe(Effect.provide(providerSessionRuntimeLayer)), + ); +}); From 90dc76b11eac811e8bc89cad3f60bb5c7b56514f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:15:22 -0700 Subject: [PATCH 43/80] Preserve asset access failure causes (#3342) Co-authored-by: codex --- apps/server/src/assets/AssetAccess.test.ts | 87 ++++++- apps/server/src/assets/AssetAccess.ts | 225 ++++++++++++++---- .../project/ProjectFaviconResolver.test.ts | 120 ++++++++++ .../src/project/ProjectFaviconResolver.ts | 123 ++++++++-- apps/server/src/ws.ts | 8 + packages/contracts/src/assets.ts | 18 ++ 6 files changed, 512 insertions(+), 69 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0cbe91765822..7df2e3361c8c 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -85,7 +86,58 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }).pipe(Effect.flip); - expect(error.message).toContain("relative to the project root"); + expect(error.message).toBe("Workspace file path must be relative to the project root."); + expect(error).toMatchObject({ + operation: "validate-workspace-path", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-permission-root-", + }); + const htmlPath = path.join(root, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

report

"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: htmlPath, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + realPath: () => Effect.fail(cause), + }); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); + + expect(error.message).toBe("Failed to inspect the workspace asset."); + expect(error).toMatchObject({ + operation: "inspect-workspace-asset", + resource: { + _tag: "workspace-file", + threadId: "thread-1", + path: htmlPath, + }, + }); + expect(error.cause).toBe(cause); }).pipe(Effect.provide(testLayer)), ); @@ -186,4 +238,37 @@ describe("AssetAccess", () => { ).toEqual({ kind: "project-favicon-fallback" }); }).pipe(Effect.provide(testLayer)), ); + + it.effect("preserves structured project favicon resolution causes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-error-", + }); + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + }); + const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: root, + relativePath: "favicon.svg", + cause: platformCause, + }); + const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + resolvePath: () => Effect.fail(resolutionCause), + }); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe( + Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver), + Effect.flip, + ); + + expect(error.message).toBe("Failed to resolve project favicon."); + expect(error.cause).toBe(resolutionCause); + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 873e9fc3d371..f7be262b41a8 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { @@ -97,33 +98,59 @@ function decodeRelativePath(value: string): string | null { } } -const failAccess = (message: string, cause?: unknown) => - new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none
()) : Effect.fail(error), + }), + ); const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const resolved = yield* workspacePaths - .resolveRelativePathWithinRoot(input) - .pipe(Effect.orElseSucceed(() => null)); - if (!resolved) return null; + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()), + }), + ); + if (Option.isNone(resolved)) return null; const [canonicalRoot, canonicalFile] = yield* Effect.all([ - fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), - fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), + optionOnNotFound(fileSystem.realPath(input.workspaceRoot)), + optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)), ]); - if (!canonicalRoot || !canonicalFile) return null; + if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null; const path = yield* Path.Path; - const relative = path.relative(canonicalRoot, canonicalFile); + const relative = path.relative(canonicalRoot.value, canonicalFile.value); if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; - const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" ? canonicalFile : null; + const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value)); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; }, ); +const resolveCanonicalWorkspaceFileForRequest = (input: { + readonly workspaceRoot: string; + readonly relativePath: string; +}) => + resolveCanonicalWorkspaceFile(input).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical asset path.", { + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; @@ -138,30 +165,78 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* failAccess("Workspace context was not found."); + return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, + message: "Workspace context was not found.", + }); } - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, + message: "Failed to normalize the workspace root.", + cause, + }), + ), + ); const relativePath = path.isAbsolute(input.resource.path) ? path.relative(workspaceRoot, input.resource.path) : input.resource.path; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "validate-workspace-path", + resource: input.resource, + message: "Workspace file path must be relative to the project root.", + cause, + }), + ), + ); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { - return yield* failAccess("Only browser documents and images can be previewed."); + return yield* new AssetAccessError({ + operation: "validate-preview-type", + resource: input.resource, + message: "Only browser documents and images can be previewed.", + }); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: resolved.relativePath, - }); + }).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "inspect-workspace-asset", + resource: input.resource, + message: "Failed to inspect the workspace asset.", + cause, + }), + ), + ); if (!canonicalFile) { - return yield* failAccess("Workspace asset was not found."); + return yield* new AssetAccessError({ + operation: "locate-workspace-asset", + resource: input.resource, + message: "Workspace asset was not found.", + }); } - const canonicalWorkspaceRoot = yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))); + const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, + message: "Failed to resolve workspace.", + cause, + }), + ), + ); claims = isWorkspaceImagePreviewPath(resolved.relativePath) ? { version: 1, @@ -187,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* failAccess("Attachment was not found."); + return yield* new AssetAccessError({ + operation: "locate-attachment", + resource: input.resource, + message: "Attachment was not found.", + }); } claims = { version: 1, @@ -199,24 +278,64 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i break; } case "project-favicon": { - const workspaceRoot = yield* workspacePaths - .normalizeWorkspaceRoot(input.resource.cwd) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "normalize-workspace-root", + resource: input.resource, + message: "Failed to normalize the workspace root.", + cause, + }), + ), + ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-project-favicon", + resource: input.resource, + message: "Failed to resolve project favicon.", + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "inspect-project-favicon", + resource: input.resource, + message: "Failed to inspect the project favicon.", + cause, + }), + ), + )) ) { - return yield* failAccess("Project favicon was not found."); + return yield* new AssetAccessError({ + operation: "locate-project-favicon", + resource: input.resource, + message: "Project favicon was not found.", + }); } claims = { version: 1, kind: "project-favicon", - workspaceRoot: yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "resolve-workspace", + resource: input.resource, + message: "Failed to resolve workspace.", + cause, + }), + ), + ), relativePath, expiresAt, }; @@ -226,9 +345,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + operation: "load-signing-key", + resource: input.resource, + message: "Failed to load the asset signing key.", + cause, + }), + ), + ); const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { @@ -245,9 +372,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!encodedPayload || !signature) return null; const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore - .getOrCreateRandom(SIGNING_SECRET_NAME, 32) - .pipe(Effect.orElseSucceed(() => null)); + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })), + Effect.orElseSucceed(() => null), + ); if (!signingSecret) return null; if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; @@ -262,8 +390,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( }); if (!attachmentPath) return null; const fileSystem = yield* FileSystem.FileSystem; - const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); - return info?.type === "File" + const info = yield* optionOnNotFound(fileSystem.stat(attachmentPath)).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to inspect attachment asset.", { + attachmentId: claims.attachmentId, + path: attachmentPath, + cause, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + return Option.isSome(info) && info.value.type === "File" ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) : null; } @@ -272,7 +409,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (claims.relativePath === null) { return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; } - const faviconPath = yield* resolveCanonicalWorkspaceFile({ + const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); @@ -284,7 +421,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const path = yield* Path.Path; if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; - const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({ + const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); @@ -303,7 +440,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } const joinedRelativePath = claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); - const workspaceFile = yield* resolveCanonicalWorkspaceFile({ + const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: joinedRelativePath, }); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 37bda11e6aab..0b017b22e4e7 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => + ProjectFaviconResolver.make.pipe( + Effect.provide(WorkspacePaths.layer), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => @@ -73,5 +80,118 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); + + it.effect("preserves workspace normalization context", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const missingCwd = `${cwd}/missing`; + + const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "normalize-workspace", + workspaceRoot: missingCwd, + }); + expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError); + }), + ); + + it.effect("preserves non-missing candidate stat failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const faviconPath = path.join(cwd, "favicon.svg"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: faviconPath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "stat-candidate", + workspaceRoot: cwd, + relativePath: "favicon.svg", + absolutePath: faviconPath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("preserves icon source read failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const sourcePath = path.join(cwd, "index.html"); + yield* writeTextFile(cwd, "index.html", ''); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: sourcePath, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (filePath, options) => + filePath === sourcePath + ? Effect.fail(cause) + : fileSystem.readFileString(filePath, options), + }), + ); + + const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ProjectFaviconResolutionError", + operation: "read-source", + workspaceRoot: cwd, + relativePath: "index.html", + absolutePath: sourcePath, + }); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("skips icon metadata paths outside the workspace", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + + it.effect("continues to later sources after an outside-root icon href", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 4c685a20f88c..e644df06ae64 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -10,7 +10,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -56,6 +59,26 @@ const LINK_ICON_HTML_RE = const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "ProjectFaviconResolutionError", + { + operation: Schema.Literals([ + "normalize-workspace", + "resolve-path", + "stat-candidate", + "read-source", + ]), + workspaceRoot: Schema.String, + relativePath: Schema.optional(Schema.String), + absolutePath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; + } +} + /** Service tag for project favicon resolution. */ export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, @@ -65,7 +88,9 @@ export class ProjectFaviconResolver extends Context.Service< * * Returns `null` when no candidate icon file can be found. */ - readonly resolvePath: (cwd: string) => Effect.Effect; + readonly resolvePath: ( + cwd: string, + ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -77,6 +102,17 @@ function extractIconHref(source: string): string | null { return null; } +const optionOnNotFound = ( + effect: Effect.Effect, +): Effect.Effect, PlatformError.PlatformError, R> => + effect.pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -90,22 +126,39 @@ export const make = Effect.gen(function* () { const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, - ): Effect.fn.Return { + ): Effect.fn.Return { for (const relativePath of relativeCandidates) { const candidate = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot: projectCwd, relativePath, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!candidate) { + .pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), + ); + if (Option.isNone(candidate)) { continue; } - const stats = yield* fileSystem - .stat(candidate.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { - return candidate.absolutePath; + const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + relativePath, + absolutePath: candidate.value.absolutePath, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return candidate.value.absolutePath; } } return null; @@ -114,12 +167,16 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd) { - const projectCwd = yield* workspacePaths - .normalizeWorkspaceRoot(cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (!projectCwd) { - return null; - } + const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "normalize-workspace", + workspaceRoot: cwd, + cause, + }), + ), + ); for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { @@ -133,17 +190,35 @@ export const make = Effect.gen(function* () { workspaceRoot: projectCwd, relativePath: sourceFile, }) - .pipe(Effect.orElseSucceed(() => null)); - if (!sourcePath) { - continue; - } - const source = yield* fileSystem - .readFileString(sourcePath.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath: sourceFile, + cause, + }), + ), + ); + const source = yield* optionOnNotFound( + fileSystem.readFileString(sourcePath.absolutePath), + ).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-source", + workspaceRoot: projectCwd, + relativePath: sourceFile, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) { continue; } - const href = extractIconHref(source); + const href = extractIconHref(source.value); if (!href) { continue; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 05e78de476cf..7ebc432038c5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1414,6 +1414,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1421,6 +1423,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(thread)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } @@ -1430,6 +1434,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Failed to resolve workspace context.", cause, }), @@ -1437,6 +1443,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ); if (Option.isNone(project)) { return yield* new AssetAccessError({ + operation: "resolve-workspace-context", + resource: input.resource, message: "Workspace context was not found.", }); } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bd1ac0a53ec6..fdfbe64246ed 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -29,9 +29,27 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export const AssetAccessOperation = Schema.Literals([ + "resolve-workspace-context", + "normalize-workspace-root", + "validate-workspace-path", + "validate-preview-type", + "inspect-workspace-asset", + "locate-workspace-asset", + "resolve-workspace", + "locate-attachment", + "resolve-project-favicon", + "inspect-project-favicon", + "locate-project-favicon", + "load-signing-key", +]); +export type AssetAccessOperation = typeof AssetAccessOperation.Type; + export class AssetAccessError extends Schema.TaggedErrorClass()( "AssetAccessError", { + operation: AssetAccessOperation, + resource: AssetResource, message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, From 5edf7c56bafeeaf4d5af1a63f010dde45e6652dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:38:56 -0700 Subject: [PATCH 44/80] [codex] Preserve PR materialization failure chains (#3443) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 59 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 49 +++++++++++++++------ packages/contracts/src/git.ts | 17 ++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bb7f91ffc39f..c06915c51b94 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2757,6 +2757,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 93, + title: "Missing fork branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/93", + baseRefName: "main", + headRefName: "feature/missing-fork-branch", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: missingForkDir, + sshUrl: missingForkDir, + }, + }, + }, + }); + + const error = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + }).pipe(Effect.flip); + + if (error._tag !== "GitPullRequestMaterializationError") { + return yield* Effect.die(error); + } + expect(error).toMatchObject({ + cwd: repoDir, + pullRequestNumber: 93, + headRepository: "octocat/codething-mvp", + headBranch: "feature/missing-fork-branch", + localBranch: "t3code/pr-93/feature/missing-fork-branch", + }); + if (!(error.cause instanceof AggregateError)) { + return yield* Effect.die(error.cause); + } + expect(error.cause.errors).toHaveLength(2); + expect(error.cause.errors).toEqual([ + expect.objectContaining({ _tag: "GitCommandError" }), + expect.objectContaining({ _tag: "GitCommandError" }), + ]); + expect(error.cause.cause).toBe(error.cause.errors[0]); + }), + ); + it.effect("launches setup only when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 46da2e6c1f94..f1fb03e7e45b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -41,7 +41,7 @@ import { type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { GitManagerError } from "@t3tools/contracts"; +import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; @@ -631,9 +631,12 @@ export const make = Effect.gen(function* () { ) => configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", { + cwd, + localBranch, + headBranch: pullRequest.headBranch, + cause: error, + }).pipe(Effect.asVoid), ), ); @@ -691,12 +694,30 @@ export const make = Effect.gen(function* () { localBranch = pullRequest.headBranch, ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( - Effect.catch(() => - gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }), + Effect.catch((primaryCause) => + gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), + ), + ), ), ); const fileSystem = yield* FileSystem.FileSystem; @@ -1452,9 +1473,11 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.preparePullRequestThread setup script failed", { + threadId: input.threadId, + worktreePath, + cause: error, + }).pipe(Effect.asVoid), ), ); }; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 0f1f09729be5..aa5cdf8432ba 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -361,8 +361,25 @@ export class GitManagerError extends Schema.TaggedErrorClass()( } } +export class GitPullRequestMaterializationError extends Schema.TaggedErrorClass()( + "GitPullRequestMaterializationError", + { + cwd: TrimmedNonEmptyStringSchema, + pullRequestNumber: PositiveInt, + headRepository: Schema.NullOr(TrimmedNonEmptyStringSchema), + headBranch: TrimmedNonEmptyStringSchema, + localBranch: TrimmedNonEmptyStringSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to materialize pull request #${this.pullRequestNumber} branch ${this.headBranch} as ${this.localBranch}.`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, + GitPullRequestMaterializationError, GitCommandError, SourceControlProviderError, TextGenerationError, From a9460bb772a8e25d39213ae62e73934152d44495 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:44:22 -0700 Subject: [PATCH 45/80] [codex] Structure pull request link failures (#3445) Co-authored-by: codex --- apps/web/src/components/GitActionsControl.tsx | 4 +- apps/web/src/lib/openPullRequestLink.test.ts | 30 ++++++++++++++ apps/web/src/lib/openPullRequestLink.ts | 40 ++++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/openPullRequestLink.test.ts diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index af3f7b47286b..c98167194524 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -88,6 +88,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { openPullRequestLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { gitCwd: string | null; @@ -1229,7 +1230,8 @@ export default function GitActionsControl({ }); return; } - void api.shell.openExternal(prUrl).catch((err: unknown) => { + void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { + console.error(err); toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts new file mode 100644 index 000000000000..756e1ed6ad9d --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openPullRequestLink, PullRequestLinkOpenError } from "./openPullRequestLink"; + +describe("openPullRequestLink", () => { + it("opens the requested pull request URL", async () => { + const openExternal = vi.fn(async () => undefined); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; + + await openPullRequestLink({ openExternal }, targetUrl); + + expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); + }); + + it("reports bridge failures with a safe target origin", async () => { + const cause = new Error("desktop shell unavailable"); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; + const openExternal = vi.fn(async () => Promise.reject(cause)); + + const result = openPullRequestLink({ openExternal }, targetUrl); + + await expect(result).rejects.toEqual( + new PullRequestLinkOpenError({ + targetOrigin: "https://github.com", + cause, + }), + ); + await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 899e5c38c58e..acd3c5a062ba 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,8 +1,45 @@ +import type { LocalApi } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { readLocalApi } from "../localApi"; +export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( + "PullRequestLinkOpenError", + { + targetOrigin: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { + let targetOrigin: string | null = null; + try { + targetOrigin = new URL(targetUrl).origin; + } catch { + // Keep malformed URLs out of diagnostics while preserving the open failure below. + } + return new PullRequestLinkOpenError({ targetOrigin, cause }); + } + + override get message(): string { + return this.targetOrigin === null + ? "Unable to open pull request link." + : `Unable to open pull request link at ${this.targetOrigin}.`; + } +} + +export async function openPullRequestLink( + shell: Pick, + targetUrl: string, +): Promise { + try { + await shell.openExternal(targetUrl); + } catch (cause) { + throw PullRequestLinkOpenError.fromCause(targetUrl, cause); + } +} + /** * Returns a click handler that opens a pull request URL in the system browser. * @@ -24,7 +61,8 @@ export function useOpenPrLink() { return; } - void api.shell.openExternal(prUrl).catch((error) => { + void openPullRequestLink(api.shell, prUrl).catch((error) => { + console.error(error); toastManager.add( stackedThreadToast({ type: "error", From 28e7c9ae17affc31e028a06cf33911fabe84d2cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:58:15 -0700 Subject: [PATCH 46/80] [codex] Structure mobile waitlist enrollment failures (#3446) Co-authored-by: codex --- .../cloud/CloudWaitlistEnrollment.tsx | 15 +++--- .../features/cloud/cloudWaitlistJoin.test.ts | 48 +++++++++++++++++++ .../src/features/cloud/cloudWaitlistJoin.ts | 46 ++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts create mode 100644 apps/mobile/src/features/cloud/cloudWaitlistJoin.ts diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx index 77b48d44b1e8..4d5b5703329d 100644 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; +import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { const { errors, fetchStatus, waitlist } = useWaitlist(); @@ -21,12 +22,14 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } setRequestError(null); try { - const { error } = await waitlist.join({ emailAddress: normalizedEmailAddress }); - if (error) { - setRequestError("Could not join the waitlist. Check your email address and try again."); - } - } catch { - setRequestError("Could not join the waitlist. Check your connection and try again."); + await joinCloudWaitlist(waitlist, normalizedEmailAddress); + } catch (error) { + console.error(error); + setRequestError( + error instanceof CloudWaitlistJoinRejectedError + ? "Could not join the waitlist. Check your email address and try again." + : "Could not join the waitlist. Check your connection and try again.", + ); } }; diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts new file mode 100644 index 000000000000..582cb40ffbf9 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + CloudWaitlistJoinRejectedError, + CloudWaitlistJoinRequestError, + joinCloudWaitlist, +} from "./cloudWaitlistJoin"; + +describe("joinCloudWaitlist", () => { + it("submits the provided email address", async () => { + const join = vi.fn().mockResolvedValue({ error: null }); + + await joinCloudWaitlist({ join }, "person@example.com"); + + expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); + }); + + it("preserves Clerk rejection details without exposing the email address", async () => { + const cause = Object.assign(new Error("The enrollment was rejected."), { + code: "form_identifier_invalid", + }); + const join = vi.fn().mockResolvedValue({ error: cause }); + + const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); + expect(failure).toMatchObject({ + code: "form_identifier_invalid", + cause, + }); + expect(String(failure)).not.toContain("secret@example.com"); + }); + + it("distinguishes request failures from rejected enrollments", async () => { + const cause = new Error("network unavailable"); + const join = vi.fn().mockRejectedValue(cause); + + const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); + expect(failure).toMatchObject({ cause }); + expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); + }); +}); diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts new file mode 100644 index 000000000000..4a467a19e4bd --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts @@ -0,0 +1,46 @@ +import * as Schema from "effect/Schema"; + +interface CloudWaitlistJoiner { + readonly join: (input: { emailAddress: string }) => Promise<{ + readonly error: { readonly code: string } | null; + }>; +} + +export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass()( + "CloudWaitlistJoinRejectedError", + { + code: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Cloud waitlist enrollment was rejected with code "${this.code}".`; + } +} + +export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass()( + "CloudWaitlistJoinRequestError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Cloud waitlist enrollment request failed."; + } +} + +export async function joinCloudWaitlist( + waitlist: CloudWaitlistJoiner, + emailAddress: string, +): Promise { + const result = await waitlist.join({ emailAddress }).catch((cause) => { + throw new CloudWaitlistJoinRequestError({ cause }); + }); + + if (result.error) { + throw new CloudWaitlistJoinRejectedError({ + code: result.error.code, + cause: result.error, + }); + } +} From 803c2b77387bbaf5f10319f8ac6e5742b68d9612 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:58:21 -0700 Subject: [PATCH 47/80] [codex] Preserve desktop backend output read failures (#3444) Co-authored-by: codex --- .../src/backend/DesktopBackendManager.test.ts | 91 +++++++++++++++++- .../src/backend/DesktopBackendManager.ts | 95 +++++++++++++++++-- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 0c083889f295..3c0a513c9b51 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -59,8 +59,8 @@ const configWithObservability: DesktopBackendBootstrapValue = { }; function makeProcess(options?: { - readonly stdout?: Stream.Stream; - readonly stderr?: Stream.Stream; + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; }): ChildProcessSpawner.ChildProcessHandle { @@ -389,6 +389,93 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("reports output stream failures with process and stream context", () => + Effect.gen(function* () { + const outputCause = PlatformError.systemError({ + _tag: "BadResource", + module: "ChildProcess", + method: "stdout", + description: "output-stream-secret-sentinel", + }); + const reported = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fail(outputCause), + exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputReadError") { + return assert.fail(`Expected output read error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.strictEqual(error.cause, outputCause); + assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); + assert.notInclude(error.message, "output-stream-secret-sentinel"); + }), + ); + + it.effect("reports output handler failures separately from stream read failures", () => + Effect.gen(function* () { + const chunk = new TextEncoder().encode("backend output"); + const outputCause = new Error("output-handler-secret-sentinel"); + const reported = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.make(chunk), + exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + onOutput: () => Effect.fail(outputCause), + onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); + const error = yield* Deferred.await(reported); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + if (error._tag !== "BackendProcessOutputHandlingError") { + return assert.fail(`Expected output handling error, received ${error._tag}`); + } + assert.equal(error.executablePath, "/electron"); + assert.equal(error.entryPath, "/server/bin.mjs"); + assert.equal(error.cwd, "/server"); + assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); + assert.equal(error.pid, 123); + assert.equal(error.streamName, "stdout"); + assert.equal(error.chunkByteLength, chunk.byteLength); + assert.strictEqual(error.cause, outputCause); + assert.equal( + error.message, + `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, + ); + assert.notInclude(error.message, "output-handler-secret-sentinel"); + }), + ); + it.effect("retries HTTP readiness before reporting the backend ready", () => Effect.gen(function* () { const requestUrls: Array = []; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8a40c9d21532..d92f62d16b7d 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -105,6 +105,39 @@ export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( + "BackendProcessOutputReadError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`; + } +} + +export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass()( + "BackendProcessOutputHandlingError", + { + ...backendProcessContextSchema, + pid: Schema.Number, + streamName: Schema.Literals(["stdout", "stderr"]), + chunkByteLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`; + } +} + +export type BackendProcessOutputError = + | BackendProcessOutputReadError + | BackendProcessOutputHandlingError; + export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( "BackendProcessExitStatusError", { @@ -146,7 +179,8 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, - ) => Effect.Effect; + ) => Effect.Effect; + readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect; } export interface DesktopBackendSnapshot { @@ -254,13 +288,41 @@ export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpRea }); function drainBackendOutput( + context: BackendProcessContext & { readonly pid: number }, streamName: BackendProcessOutputStream, stream: Stream.Stream, - onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, + onOutput: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect, + onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect, ): Effect.Effect { return stream.pipe( - Stream.runForEach((chunk) => onOutput(streamName, chunk)), - Effect.ignore, + Stream.mapError( + (cause) => + new BackendProcessOutputReadError({ + ...context, + streamName, + cause, + }), + ), + Stream.runForEach((chunk) => + onOutput(streamName, chunk).pipe( + Effect.mapError( + (cause) => + new BackendProcessOutputHandlingError({ + ...context, + streamName, + chunkByteLength: chunk.byteLength, + cause, + }), + ), + ), + ), + Effect.catchTags({ + BackendProcessOutputReadError: onOutputFailure, + BackendProcessOutputHandlingError: onOutputFailure, + }), ); } @@ -321,8 +383,28 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* options.onStarted?.(handle.pid) ?? Effect.void; if (options.captureOutput) { - yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); - yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + const outputContext = { + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + pid: Number(handle.pid), + }; + const onOutputFailure = options.onOutputFailure ?? (() => Effect.void); + yield* drainBackendOutput( + outputContext, + "stdout", + handle.stdout, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped); + yield* drainBackendOutput( + outputContext, + "stderr", + handle.stderr, + onOutput, + onOutputFailure, + ).pipe(Effect.forkScoped); } yield* waitForHttpReady({ executablePath: options.executablePath, @@ -560,6 +642,7 @@ export const make = Effect.gen(function* () { error, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onOutputFailure: (error) => logBackendManagerError(error.message, { error }), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), From 9243ead1c3eebb6316321012cf42a21737965a56 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:09:11 -0700 Subject: [PATCH 48/80] [codex] Preserve Linux icon resize fallback failures (#3447) Co-authored-by: codex --- scripts/build-desktop-artifact.test.ts | 76 ++++++++++++++++++++++++++ scripts/build-desktop-artifact.ts | 34 ++++++++++-- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index f8c354a85992..62823f7fc819 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -4,6 +4,9 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { BuildScriptError, @@ -14,6 +17,7 @@ import { InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, isMacPasskeySigningConfigurationError, + LinuxIconResizeError, MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, @@ -27,11 +31,49 @@ import { resolveGitHubPublishConfig, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, + stageLinuxIconSize, STAGE_INSTALL_ARGS, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +function mockProcess(exitCode: number) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function iconResizeSpawnerLayer( + commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>, + exitCodes: ReadonlyArray, +) { + let commandIndex = 0; + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed(mockProcess(exitCodes[commandIndex++] ?? 0)); + }), + ); +} + it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); @@ -184,6 +226,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); }); + it.effect("preserves both Linux icon resize failures with structural context", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + return Effect.gen(function* () { + const error = yield* stageLinuxIconSize("source.png", "target.png", 512, false).pipe( + Effect.provide(iconResizeSpawnerLayer(commands, [1, 2])), + Effect.flip, + ); + + assert.instanceOf(error, LinuxIconResizeError); + assert.equal(error.operation, "resize"); + assert.equal(error.iconSize, 512); + assert.equal(error.primaryTool, "magick"); + assert.equal(error.fallbackTool, "convert"); + assert.include(error.message, "512x512"); + assert.include(error.message, "`magick`"); + assert.include(error.message, "`convert`"); + assert.notInclude(error.message, "non-zero exit code"); + + assert.instanceOf(error.cause, AggregateError); + const aggregateCause = error.cause as AggregateError; + assert.lengthOf(aggregateCause.errors, 2); + assert.strictEqual(aggregateCause.cause, aggregateCause.errors[0]); + assert.instanceOf(aggregateCause.errors[0], BuildScriptError); + assert.instanceOf(aggregateCause.errors[1], BuildScriptError); + assert.include((aggregateCause.errors[0] as BuildScriptError).message, "magick linux icon"); + assert.include((aggregateCause.errors[1] as BuildScriptError).message, "convert linux icon"); + assert.deepStrictEqual( + commands.map(({ command }) => command), + ["magick", "convert"], + ); + }); + }); + it("derives macOS passkey signing configuration from the Clerk publishable key", () => { const configuration = resolveMacPasskeySigningConfiguration({ T3CODE_APPLE_TEAM_ID: "abc1234567", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 6f13783f2d1c..f1d03f615097 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -142,6 +142,21 @@ export class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ } } +export class LinuxIconResizeError extends Schema.TaggedErrorClass()( + "LinuxIconResizeError", + { + operation: Schema.Literal("resize"), + iconSize: Schema.Int, + primaryTool: Schema.Literal("magick"), + fallbackTool: Schema.Literal("convert"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} the Linux desktop icon to ${this.iconSize}x${this.iconSize} with \`${this.primaryTool}\` or \`${this.fallbackTool}\`. Install ImageMagick so either tool is available.`; + } +} + const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), @@ -877,7 +892,7 @@ function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: }); } -function stageLinuxIconSize( +export function stageLinuxIconSize( sourcePng: string, targetPng: string, iconSize: number, @@ -890,13 +905,20 @@ function stageLinuxIconSize( ); return resize("magick").pipe( - Effect.catch(() => + Effect.catch((primaryCause) => resize("convert").pipe( Effect.mapError( - () => - new BuildScriptError({ - message: - "ImageMagick is required to generate Linux desktop icon sizes. Install ImageMagick so either `magick` or `convert` is available.", + (fallbackCause) => + new LinuxIconResizeError({ + operation: "resize", + iconSize, + primaryTool: "magick", + fallbackTool: "convert", + cause: new AggregateError( + [primaryCause, fallbackCause], + "Both Linux icon resize tool attempts failed.", + { cause: primaryCause }, + ), }), ), ), From 9f7861aadd257313efefdc690e440d23d771f315 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:09:17 -0700 Subject: [PATCH 49/80] [codex] Structure desktop update persistence errors (#3261) Co-authored-by: codex --- .../src/settings/DesktopAppSettings.ts | 67 +++-- .../src/updates/DesktopUpdates.test.ts | 262 ++++++++++++++++- apps/desktop/src/updates/DesktopUpdates.ts | 275 ++++++++++++++---- 3 files changed, 524 insertions(+), 80 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index e072d80f03e5..81aae92f0a3f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -83,12 +83,6 @@ export class DesktopSettingsWriteError extends Schema.TaggedErrorClass new DesktopSettingsWriteError({ operation, path, cause }); - export class DesktopAppSettings extends Context.Service< DesktopAppSettings, { @@ -244,18 +238,46 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), - ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause))); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.settingsPath) - .pipe( - Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), - ); + ).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "encode-document", + path: input.settingsPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: input.settingsPath, + cause, + }), + ), + ); }); export const make = Effect.gen(function* () { @@ -276,8 +298,13 @@ export const make = Effect.gen(function* () { return crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.desktopSettingsPath, cause), + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.desktopSettingsPath, + cause, + }), ), Effect.flatMap((suffix) => writeSettings({ diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ad234df0bb5d..4c90afb2a126 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -7,7 +7,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; @@ -24,6 +27,9 @@ interface UpdatesHarnessOptions { void, ElectronUpdater.ElectronUpdaterCheckForUpdatesError >; + readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; + readonly setDisableDifferentialDownload?: Effect.Effect; + readonly stopBackend?: Effect.Effect; readonly env?: Record; } @@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), - setDisableDifferentialDownload: () => Effect.void, + setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), @@ -103,7 +109,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, - stop: () => Effect.void, + stop: () => options.stopBackend ?? Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ desiredRunning: false, @@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ), ); + const setUpdateChannelError = options.setUpdateChannelError; + const settingsLayer = setUpdateChannelError + ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.fail(setUpdateChannelError), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) + : DesktopAppSettings.layer; + const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(settingsLayer), Layer.provideMerge( DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, @@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { } describe("DesktopUpdates", () => { + it("preserves complete causes for update poller and event failures", () => { + const cause = Cause.combine( + Cause.fail(new Error("updater failed")), + Cause.die(new Error("updater defect")), + ); + const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ + poller: "startup", + cause, + }); + const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ + event: "download-progress", + cause, + }); + const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ + operation: "download", + cause, + }); + const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ + action: "install", + cause, + }); + + assert.strictEqual(pollerError.cause, cause); + assert.equal(pollerError.poller, "startup"); + assert.equal(pollerError.message, "Desktop update startup poller failed."); + assert.strictEqual(eventError.cause, cause); + assert.equal(eventError.event, "download-progress"); + assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); + assert.strictEqual(reportedError.cause, cause); + assert.equal(reportedError.operation, "download"); + assert.equal(reportedError.message, "Desktop updater download operation reported an error."); + assert.strictEqual(unexpectedActionError.cause, cause); + assert.equal(unexpectedActionError.action, "install"); + assert.equal( + unexpectedActionError.message, + "Desktop update install action failed unexpectedly.", + ); + }); + it.effect("configures the updater and runs startup checks on the test clock", () => { const harness = makeHarness(); @@ -222,6 +278,178 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("keeps raw updater event failures out of update state", () => { + const harness = makeHarness(); + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("error", cause); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "error"); + assert.equal(state.message, "Desktop updater background operation reported an error."); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("logs bounded updater failure context without exposing the cause", () => { + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ + channel: null, + cause, + }); + const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) }); + const loggedAnnotations: Array> = []; + const logger = Logger.make(({ fiber }) => { + const annotations = fiber.getRef(References.CurrentLogAnnotations); + if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") { + loggedAnnotations.push(annotations); + } + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.check("manual"); + + const state = yield* updates.getState; + const loggedAnnotation = loggedAnnotations.at(-1); + assert.isDefined(loggedAnnotation); + assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); + assert.isNull(loggedAnnotation.channel); + assert.notProperty(loggedAnnotation, "error"); + assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); + assert.equal( + state.message, + "Electron updater failed to check for updates on channel default.", + ); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + harness.layer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("recovers download state after an unexpected setup failure", () => { + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + return disableDifferentialCalls === 1 + ? Effect.void + : Effect.die(new Error("download setup failed")); + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.download; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "available"); + assert.equal(failedState.errorContext, "download"); + assert.equal(failedState.message, "Desktop update download action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("restores download state and permits retry after interruption", () => + Effect.gen(function* () { + const actionStarted = yield* Deferred.make(); + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + if (disableDifferentialCalls === 1) { + return Effect.void; + } + if (disableDifferentialCalls === 2) { + return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); + } + return Effect.void; + }), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const downloadFiber = yield* updates.download.pipe(Effect.forkScoped); + yield* Deferred.await(actionStarted); + yield* Fiber.interrupt(downloadFiber); + + const interruptedState = yield* updates.getState; + assert.equal(interruptedState.status, "available"); + assert.isNull(interruptedState.message); + + const retry = yield* updates.download; + assert.isTrue(retry.accepted); + assert.isTrue(retry.completed); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + + it.effect("clears quitting state after an unexpected install setup failure", () => { + const harness = makeHarness({ + stopBackend: Effect.die(new Error("backend stop failed")), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "downloaded"); + assert.equal(failedState.errorContext, "install"); + assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); @@ -284,6 +512,7 @@ describe("DesktopUpdates", () => { const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); assert.equal(error.action, "check"); + assert.equal(error.requestedChannel, "nightly"); } yield* Deferred.succeed(releaseCheck, undefined); @@ -292,4 +521,31 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), ); + + it.effect("preserves settings failure context when an update channel cannot be persisted", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/settings.json", + cause: diskFailure, + }); + const harness = makeHarness({ setUpdateChannelError: settingsFailure }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const error = yield* updates.setChannel("nightly").pipe(Effect.flip); + + assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); + assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); + assert.equal(error.channel, "nightly"); + assert.strictEqual(error.cause, settingsFailure); + assert.strictEqual(error.cause.cause, diskFailure); + assert.equal(error.message, "Failed to persist the nightly desktop update channel."); + assert.notInclude(error.message, diskFailure.message); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index e9142c369e51..aecbdcfc3e8a 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,9 +1,10 @@ -import type { - DesktopRuntimeInfo, - DesktopUpdateActionResult, - DesktopUpdateChannel, - DesktopUpdateCheckResult, - DesktopUpdateState, +import { + DesktopUpdateChannelSchema, + type DesktopRuntimeInfo, + type DesktopUpdateActionResult, + type DesktopUpdateChannel, + type DesktopUpdateCheckResult, + type DesktopUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -63,30 +64,82 @@ export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass< "DesktopUpdateActionInProgressError", { action: Schema.Literals(["check", "download", "install"]), + requestedChannel: DesktopUpdateChannelSchema, + }, +) { + override get message(): string { + return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`; + } +} + +export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass()( + "DesktopUpdateChannelPersistenceError", + { + channel: DesktopUpdateChannelSchema, + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist the ${this.channel} desktop update channel.`; + } +} + +export class DesktopUpdatePollerError extends Schema.TaggedErrorClass()( + "DesktopUpdatePollerError", + { + poller: Schema.Literals(["startup", "poll"]), + cause: Schema.Defect(), }, ) { override get message(): string { - return `Cannot change update tracks while an update ${this.action} action is in progress.`; + return `Desktop update ${this.poller} poller failed.`; } } -export class DesktopUpdatePersistenceError extends Schema.TaggedErrorClass()( - "DesktopUpdatePersistenceError", +export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass()( + "DesktopUpdateEventHandlingError", { + event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]), cause: Schema.Defect(), }, ) { override get message(): string { - const detail = this.cause instanceof Error ? this.cause.message : String(this.cause); - return `Failed to persist desktop update settings: ${detail}`; + return `Failed to handle desktop update ${this.event} event.`; + } +} + +export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass()( + "DesktopUpdaterReportedError", + { + operation: Schema.Literals(["check", "download", "install", "background"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop updater ${this.operation} operation reported an error.`; + } +} + +export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass()( + "DesktopUpdateUnexpectedActionError", + { + action: Schema.Literals(["download", "install"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop update ${this.action} action failed unexpectedly.`; } } export type DesktopUpdateConfigureError = never; -export type DesktopUpdateSetChannelError = - | DesktopUpdateActionInProgressError - | DesktopUpdatePersistenceError; +export const DesktopUpdateSetChannelError = Schema.Union([ + DesktopUpdateActionInProgressError, + DesktopUpdateChannelPersistenceError, +]); +export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; +export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, @@ -308,16 +361,21 @@ export const make = Effect.gen(function* () { return yield* electronUpdater.checkForUpdates.pipe( Effect.as(true), - Effect.catch( - Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { + Effect.catchTags({ + ElectronUpdaterCheckForUpdatesError: Effect.fn( + "desktop.updates.handleCheckForUpdatesFailure", + )(function* (error) { const failedAt = yield* currentIsoTimestamp; yield* updateState((current) => reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), ); - yield* logUpdaterError("failed to check for updates", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); return true; }), - ), + }), Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), ); }); @@ -342,19 +400,50 @@ export const make = Effect.gen(function* () { yield* electronUpdater.downloadUpdate; return { accepted: true, completed: true }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { + Effect.catchTags({ + ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( + function* (error) { + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => + updateState((current) => (current.status === "downloading" ? state : current)).pipe( + Effect.asVoid, + ), + ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause }); + return Effect.gen(function* () { yield* updateState((current) => reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); - yield* logUpdaterError("failed to download update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; - }), - ), + }); + }), Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + const resetInstallAction = Effect.all( + [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)], + { discard: true }, + ); + const installDownloadedUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); if ( @@ -377,14 +466,38 @@ export const make = Effect.gen(function* () { }); return { accepted: true, completed: false }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { - yield* Ref.set(updateInstallInFlightRef, false); + Effect.catchTags({ + ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( + function* (error) { + yield* resetInstallAction; + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + isSilent: error.isSilent, + isForceRunAfter: error.isForceRunAfter, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => resetInstallAction), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + yield* resetInstallAction; + const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message), ); - yield* Ref.set(desktopState.quitting, false); - yield* logUpdaterError("failed to install update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; }), ), @@ -394,17 +507,31 @@ export const make = Effect.gen(function* () { const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( Effect.andThen(checkForUpdates("startup")), - Effect.catchCause((cause) => - logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "startup", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( Effect.andThen(checkForUpdates("poll")), Effect.forever, - Effect.catchCause((cause) => - logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "poll", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); }).pipe(Effect.withSpan("desktop.updates.startPollers")); @@ -435,11 +562,16 @@ export const make = Effect.gen(function* () { yield* logUpdaterInfo("update available", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-available event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -452,14 +584,23 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( - error: unknown, + cause: unknown, ) { - const message = error instanceof Error ? error.message : String(error); + const activeAction = yield* activeUpdateAction; + const error = new DesktopUpdaterReportedError({ + operation: Option.getOrElse(activeAction, () => "background" as const), + cause, + }); if (yield* Ref.get(updateInstallInFlightRef)) { yield* Ref.set(updateInstallInFlightRef, false); yield* Ref.set(desktopState.quitting, false); - yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); - yield* logUpdaterError("updater error", { message }); + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); return; } @@ -469,7 +610,7 @@ export const make = Effect.gen(function* () { yield* updateState((current) => ({ ...current, status: "error", - message, + message: error.message, checkedAt, downloadPercent: null, errorContext, @@ -477,7 +618,10 @@ export const make = Effect.gen(function* () { })); } - yield* logUpdaterError("updater error", { message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); }); const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( @@ -499,11 +643,16 @@ export const make = Effect.gen(function* () { } }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed download-progress event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -518,11 +667,16 @@ export const make = Effect.gen(function* () { yield* logUpdaterInfo("update downloaded", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-downloaded event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -598,7 +752,10 @@ export const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); const activeAction = yield* activeUpdateAction; if (Option.isSome(activeAction)) { - return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); + return yield* new DesktopUpdateActionInProgressError({ + action: activeAction.value, + requestedChannel: nextChannel, + }); } const state = yield* Ref.get(updateStateRef); @@ -608,7 +765,11 @@ export const make = Effect.gen(function* () { yield* desktopSettings .setUpdateChannel(nextChannel) - .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }), + ), + ); const enabled = yield* shouldEnableAutoUpdates; yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); From 4cd958445f60dcdbc5ad3997a35b1bd995e9d725 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:25:27 -0700 Subject: [PATCH 50/80] [codex] Migrate desktop app errors to Schema (#3449) Co-authored-by: codex --- apps/desktop/src/app/DesktopApp.ts | 24 ++++++++------- apps/desktop/src/app/DesktopAppErrors.test.ts | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/app/DesktopAppErrors.test.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index f498c3340e61..214fd383e042 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,8 +1,8 @@ import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as NetService from "@t3tools/shared/Net"; import * as Crypto from "effect/Crypto"; @@ -33,22 +33,24 @@ const makeDesktopRunId = Crypto.Crypto.pipe( Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), ); -class DesktopBackendPortUnavailableError extends Data.TaggedError( +export class DesktopBackendPortUnavailableError extends Schema.TaggedErrorClass()( "DesktopBackendPortUnavailableError", -)<{ - readonly startPort: number; - readonly maxPort: number; - readonly hosts: readonly string[]; -}> { - override get message() { + { + startPort: Schema.Int, + maxPort: Schema.Int, + hosts: Schema.Array(Schema.String), + }, +) { + override get message(): string { return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; } } -class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( +export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErrorClass()( "DesktopDevelopmentBackendPortRequiredError", -)<{}> { - override get message() { + {}, +) { + override get message(): string { return "T3CODE_PORT is required in desktop development."; } } diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts new file mode 100644 index 000000000000..666c36d391de --- /dev/null +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -0,0 +1,30 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + DesktopBackendPortUnavailableError, + DesktopDevelopmentBackendPortRequiredError, +} from "./DesktopApp.ts"; + +describe("DesktopApp errors", () => { + it("preserves unavailable backend port context", () => { + const error = new DesktopBackendPortUnavailableError({ + startPort: 3_773, + maxPort: 65_535, + hosts: ["127.0.0.1", "0.0.0.0", "::"], + }); + + assert.equal(error.startPort, 3_773); + assert.equal(error.maxPort, 65_535); + assert.deepEqual(error.hosts, ["127.0.0.1", "0.0.0.0", "::"]); + assert.equal( + error.message, + "No desktop backend port is available on hosts 127.0.0.1, 0.0.0.0, :: between 3773 and 65535.", + ); + }); + + it("reports the required development port", () => { + const error = new DesktopDevelopmentBackendPortRequiredError(); + + assert.equal(error.message, "T3CODE_PORT is required in desktop development."); + }); +}); From 68c0dd3b7095cae054a9db5e48157e89f65c0664 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:35:02 -0700 Subject: [PATCH 51/80] [codex] Structure server CLI failures (#3450) Co-authored-by: codex --- apps/server/scripts/cli.ts | 43 ++++++++-------- apps/server/scripts/cliErrors.test.ts | 31 ++++++++++++ apps/server/scripts/cliErrors.ts | 70 +++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 apps/server/scripts/cliErrors.test.ts create mode 100644 apps/server/scripts/cliErrors.ts diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index a158eaa068d6..00b6c4cfcceb 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; @@ -20,6 +19,14 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import serverPackageJson from "../package.json" with { type: "json" }; +import { + ServerCliBuildAssetMissingError, + ServerCliCommandExitError, + ServerCliDevelopmentIconSourceMissingError, + ServerCliDevelopmentIconTargetMissingError, + ServerCliPublishIconSourceMissingError, + ServerCliPublishIconTargetMissingError, +} from "./cliErrors.ts"; interface PackageJson { name: string; @@ -47,11 +54,6 @@ const WorkspaceConfig = Schema.Struct({ type WorkspaceConfig = typeof WorkspaceConfig.Type; const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); -class CliError extends Data.TaggedError("CliError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), ); @@ -64,14 +66,17 @@ const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { return yield* decodeWorkspaceConfig(workspaceYaml); }); -const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Command) { +const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(command); const exitCode = yield* child.exitCode; if (exitCode !== 0) { - return yield* new CliError({ - message: `Command exited with non-zero exit code (${exitCode})`, + return yield* new ServerCliCommandExitError({ + command: command.command, + args: command.args, + cwd: command.options.cwd, + exitCode, }); } }); @@ -95,14 +100,10 @@ const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(functio const backupPath = `${targetPath}.publish-bak`; if (!(yield* fs.exists(sourcePath))) { - return yield* new CliError({ - message: `Missing publish icon source: ${sourcePath}`, - }); + return yield* new ServerCliPublishIconSourceMissingError({ sourcePath }); } if (!(yield* fs.exists(targetPath))) { - return yield* new CliError({ - message: `Missing publish icon target: ${targetPath}. Run the build subcommand first.`, - }); + return yield* new ServerCliPublishIconTargetMissingError({ targetPath }); } yield* fs.copyFile(targetPath, backupPath); @@ -138,14 +139,10 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides") const targetPath = path.join(serverDir, override.targetRelativePath); if (!(yield* fs.exists(sourcePath))) { - return yield* new CliError({ - message: `Missing development icon source: ${sourcePath}`, - }); + return yield* new ServerCliDevelopmentIconSourceMissingError({ sourcePath }); } if (!(yield* fs.exists(targetPath))) { - return yield* new CliError({ - message: `Missing development icon target: ${targetPath}. Build web first.`, - }); + return yield* new ServerCliDevelopmentIconTargetMissingError({ targetPath }); } yield* fs.copyFile(sourcePath, targetPath); @@ -245,9 +242,7 @@ const publishCmd = Command.make( for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { - return yield* new CliError({ - message: `Missing build asset: ${abs}. Run the build subcommand first.`, - }); + return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); } } diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts new file mode 100644 index 000000000000..91754290db9a --- /dev/null +++ b/apps/server/scripts/cliErrors.test.ts @@ -0,0 +1,31 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts"; + +describe("server CLI errors", () => { + it("preserves failed command context without changing its message", () => { + const error = new ServerCliCommandExitError({ + command: "vp", + args: ["pm", "publish"], + cwd: "/repo", + exitCode: 17, + }); + + assert.equal(error._tag, "ServerCliCommandExitError"); + assert.equal(error.command, "vp"); + assert.deepEqual(error.args, ["pm", "publish"]); + assert.equal(error.cwd, "/repo"); + assert.equal(error.exitCode, 17); + assert.equal(error.message, "Command exited with non-zero exit code (17)"); + }); + + it("preserves a representative missing asset path", () => { + const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" }); + + assert.equal(error.assetPath, "/repo/server.mjs"); + assert.equal( + error.message, + "Missing build asset: /repo/server.mjs. Run the build subcommand first.", + ); + }); +}); diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts new file mode 100644 index 000000000000..d384c745f293 --- /dev/null +++ b/apps/server/scripts/cliErrors.ts @@ -0,0 +1,70 @@ +import * as Schema from "effect/Schema"; + +export class ServerCliCommandExitError extends Schema.TaggedErrorClass()( + "ServerCliCommandExitError", + { + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.optional(Schema.String), + exitCode: Schema.Int, + }, +) { + override get message(): string { + return `Command exited with non-zero exit code (${this.exitCode})`; + } +} + +export class ServerCliPublishIconSourceMissingError extends Schema.TaggedErrorClass()( + "ServerCliPublishIconSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing publish icon source: ${this.sourcePath}`; + } +} + +export class ServerCliPublishIconTargetMissingError extends Schema.TaggedErrorClass()( + "ServerCliPublishIconTargetMissingError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`; + } +} + +export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedErrorClass()( + "ServerCliDevelopmentIconSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing development icon source: ${this.sourcePath}`; + } +} + +export class ServerCliDevelopmentIconTargetMissingError extends Schema.TaggedErrorClass()( + "ServerCliDevelopmentIconTargetMissingError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing development icon target: ${this.targetPath}. Build web first.`; + } +} + +export class ServerCliBuildAssetMissingError extends Schema.TaggedErrorClass()( + "ServerCliBuildAssetMissingError", + { + assetPath: Schema.String, + }, +) { + override get message(): string { + return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`; + } +} From 0eba548282b96ee420ad0b1a3f5d9d4487376b90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:35:07 -0700 Subject: [PATCH 52/80] [codex] Model asset access failures with distinct errors (#3448) Co-authored-by: codex --- apps/server/src/assets/AssetAccess.test.ts | 5 +- apps/server/src/assets/AssetAccess.ts | 71 ++++---- apps/server/src/ws.ts | 19 +-- packages/contracts/src/assets.ts | 184 ++++++++++++++++++--- 4 files changed, 200 insertions(+), 79 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 7df2e3361c8c..f790e71f5cdc 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -88,7 +88,7 @@ describe("AssetAccess", () => { }).pipe(Effect.flip); expect(error.message).toBe("Workspace file path must be relative to the project root."); expect(error).toMatchObject({ - operation: "validate-workspace-path", + _tag: "AssetWorkspacePathValidationError", resource: { _tag: "workspace-file", threadId: "thread-1", @@ -130,7 +130,7 @@ describe("AssetAccess", () => { expect(error.message).toBe("Failed to inspect the workspace asset."); expect(error).toMatchObject({ - operation: "inspect-workspace-asset", + _tag: "AssetWorkspaceAssetInspectionError", resource: { _tag: "workspace-file", threadId: "thread-1", @@ -268,6 +268,7 @@ describe("AssetAccess", () => { ); expect(error.message).toBe("Failed to resolve project favicon."); + expect(error._tag).toBe("AssetProjectFaviconResolutionError"); expect(error.cause).toBe(resolutionCause); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index f7be262b41a8..8d8ecbc2af35 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,5 +1,18 @@ import type { AssetResource } from "@t3tools/contracts"; -import { AssetAccessError } from "@t3tools/contracts"; +import { + AssetAttachmentNotFoundError, + AssetPreviewTypeValidationError, + AssetProjectFaviconInspectionError, + AssetProjectFaviconNotFoundError, + AssetProjectFaviconResolutionError, + AssetSigningKeyLoadError, + AssetWorkspaceAssetInspectionError, + AssetWorkspaceAssetNotFoundError, + AssetWorkspaceContextNotFoundError, + AssetWorkspacePathValidationError, + AssetWorkspaceResolutionError, + AssetWorkspaceRootNormalizationError, +} from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, @@ -165,19 +178,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "workspace-file": { if (!input.workspaceRoot) { - return yield* new AssetAccessError({ - operation: "resolve-workspace-context", + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - message: "Workspace context was not found.", }); } const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "normalize-workspace-root", + new AssetWorkspaceRootNormalizationError({ resource: input.resource, - message: "Failed to normalize the workspace root.", cause, }), ), @@ -190,19 +199,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "validate-workspace-path", + new AssetWorkspacePathValidationError({ resource: input.resource, - message: "Workspace file path must be relative to the project root.", cause, }), ), ); if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { - return yield* new AssetAccessError({ - operation: "validate-preview-type", + return yield* new AssetPreviewTypeValidationError({ resource: input.resource, - message: "Only browser documents and images can be previewed.", }); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ @@ -211,28 +216,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "inspect-workspace-asset", + new AssetWorkspaceAssetInspectionError({ resource: input.resource, - message: "Failed to inspect the workspace asset.", cause, }), ), ); if (!canonicalFile) { - return yield* new AssetAccessError({ - operation: "locate-workspace-asset", + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource, - message: "Workspace asset was not found.", }); } const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace", + new AssetWorkspaceResolutionError({ resource: input.resource, - message: "Failed to resolve workspace.", cause, }), ), @@ -262,10 +261,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, }); if (!attachmentPath) { - return yield* new AssetAccessError({ - operation: "locate-attachment", + return yield* new AssetAttachmentNotFoundError({ resource: input.resource, - message: "Attachment was not found.", }); } claims = { @@ -281,10 +278,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "normalize-workspace-root", + new AssetWorkspaceRootNormalizationError({ resource: input.resource, - message: "Failed to normalize the workspace root.", cause, }), ), @@ -293,10 +288,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-project-favicon", + new AssetProjectFaviconResolutionError({ resource: input.resource, - message: "Failed to resolve project favicon.", cause, }), ), @@ -307,19 +300,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "inspect-project-favicon", + new AssetProjectFaviconInspectionError({ resource: input.resource, - message: "Failed to inspect the project favicon.", cause, }), ), )) ) { - return yield* new AssetAccessError({ - operation: "locate-project-favicon", + return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, - message: "Project favicon was not found.", }); } claims = { @@ -328,10 +317,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace", + new AssetWorkspaceResolutionError({ resource: input.resource, - message: "Failed to resolve workspace.", cause, }), ), @@ -348,10 +335,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "load-signing-key", + new AssetSigningKeyLoadError({ resource: input.resource, - message: "Failed to load the asset signing key.", cause, }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7ebc432038c5..554a942d78aa 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -46,7 +46,8 @@ import { OrchestrationReplayEventsError, type FilesystemBrowseFailure, FilesystemBrowseError, - AssetAccessError, + AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -1413,19 +1414,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace-context", + new AssetWorkspaceContextResolutionError({ resource: input.resource, - message: "Failed to resolve workspace context.", cause, }), ), ); if (Option.isNone(thread)) { - return yield* new AssetAccessError({ - operation: "resolve-workspace-context", + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - message: "Workspace context was not found.", }); } const project = yield* projectionSnapshotQuery @@ -1433,19 +1430,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => .pipe( Effect.mapError( (cause) => - new AssetAccessError({ - operation: "resolve-workspace-context", + new AssetWorkspaceContextResolutionError({ resource: input.resource, - message: "Failed to resolve workspace context.", cause, }), ), ); if (Option.isNone(project)) { - return yield* new AssetAccessError({ - operation: "resolve-workspace-context", + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - message: "Workspace context was not found.", }); } return yield* issueAssetUrl({ diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index fdfbe64246ed..0dbe7d9fa259 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -29,28 +29,170 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; -export const AssetAccessOperation = Schema.Literals([ - "resolve-workspace-context", - "normalize-workspace-root", - "validate-workspace-path", - "validate-preview-type", - "inspect-workspace-asset", - "locate-workspace-asset", - "resolve-workspace", - "locate-attachment", - "resolve-project-favicon", - "inspect-project-favicon", - "locate-project-favicon", - "load-signing-key", -]); -export type AssetAccessOperation = typeof AssetAccessOperation.Type; +export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( + "AssetWorkspaceContextNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Workspace context was not found."; + } +} + +export class AssetWorkspaceContextResolutionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceContextResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve workspace context."; + } +} + +export class AssetWorkspaceRootNormalizationError extends Schema.TaggedErrorClass()( + "AssetWorkspaceRootNormalizationError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to normalize the workspace root."; + } +} + +export class AssetWorkspacePathValidationError extends Schema.TaggedErrorClass()( + "AssetWorkspacePathValidationError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Workspace file path must be relative to the project root."; + } +} + +export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass()( + "AssetPreviewTypeValidationError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Only browser documents and images can be previewed."; + } +} + +export class AssetWorkspaceAssetInspectionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceAssetInspectionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to inspect the workspace asset."; + } +} + +export class AssetWorkspaceAssetNotFoundError extends Schema.TaggedErrorClass()( + "AssetWorkspaceAssetNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Workspace asset was not found."; + } +} + +export class AssetWorkspaceResolutionError extends Schema.TaggedErrorClass()( + "AssetWorkspaceResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve workspace."; + } +} -export class AssetAccessError extends Schema.TaggedErrorClass()( - "AssetAccessError", +export class AssetAttachmentNotFoundError extends Schema.TaggedErrorClass()( + "AssetAttachmentNotFoundError", { - operation: AssetAccessOperation, resource: AssetResource, - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), }, -) {} +) { + override get message(): string { + return "Attachment was not found."; + } +} + +export class AssetProjectFaviconResolutionError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconResolutionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve project favicon."; + } +} + +export class AssetProjectFaviconInspectionError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconInspectionError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to inspect the project favicon."; + } +} + +export class AssetProjectFaviconNotFoundError extends Schema.TaggedErrorClass()( + "AssetProjectFaviconNotFoundError", + { + resource: AssetResource, + }, +) { + override get message(): string { + return "Project favicon was not found."; + } +} + +export class AssetSigningKeyLoadError extends Schema.TaggedErrorClass()( + "AssetSigningKeyLoadError", + { + resource: AssetResource, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load the asset signing key."; + } +} + +export const AssetAccessError = Schema.Union([ + AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, + AssetWorkspaceRootNormalizationError, + AssetWorkspacePathValidationError, + AssetPreviewTypeValidationError, + AssetWorkspaceAssetInspectionError, + AssetWorkspaceAssetNotFoundError, + AssetWorkspaceResolutionError, + AssetAttachmentNotFoundError, + AssetProjectFaviconResolutionError, + AssetProjectFaviconInspectionError, + AssetProjectFaviconNotFoundError, + AssetSigningKeyLoadError, +]); +export type AssetAccessError = typeof AssetAccessError.Type; From 0488e47d9c0d0ca42595341b15c1d187ea7107c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:07:22 -0700 Subject: [PATCH 53/80] [codex] Structure checkpoint diff failures (#3453) Co-authored-by: codex --- .../checkpointing/CheckpointDiffQuery.test.ts | 10 +- .../src/checkpointing/CheckpointDiffQuery.ts | 69 +++++++------ apps/server/src/checkpointing/Errors.test.ts | 39 ++++++++ apps/server/src/checkpointing/Errors.ts | 98 ++++++++++++++----- 4 files changed, 164 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/checkpointing/Errors.test.ts diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8654fa0fec13..c1dbc8337183 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -9,6 +9,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn import { checkpointRefForThreadTurn } from "./Utils.ts"; import * as CheckpointDiffQuery from "./CheckpointDiffQuery.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { CheckpointThreadNotFoundError } from "./Errors.ts"; function makeThreadCheckpointContext(input: { readonly projectId: ProjectId; @@ -412,7 +413,14 @@ describe("CheckpointDiffQuery.layer", () => { }); }).pipe(Effect.provide(layer), Effect.flip); - expect(error.message).toContain("Thread 'thread-missing' not found."); + expect(error).toBeInstanceOf(CheckpointThreadNotFoundError); + expect(error).toMatchObject({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + }); + expect(error.message).toBe( + "Checkpoint invariant violation in CheckpointDiffQuery.getTurnDiff: Thread 'thread-missing' not found.", + ); }), ); }); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index d42c58dfff3c..077506ff3a84 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -22,7 +22,13 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { CheckpointInvariantError, CheckpointUnavailableError } from "./Errors.ts"; +import { + CheckpointDiffResultInvalidError, + CheckpointRefUnavailableError, + CheckpointThreadNotFoundError, + CheckpointTurnRangeUnavailableError, + CheckpointWorkspacePathMissingError, +} from "./Errors.ts"; import type { CheckpointServiceError } from "./Errors.ts"; import { checkpointRefForThreadTurn } from "./Utils.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; @@ -92,9 +98,9 @@ export const make = Effect.gen(function* () { diff: "", }; if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed turn diff result does not satisfy contract schema.", + threadId: input.threadId, }); } return emptyDiff; @@ -104,9 +110,9 @@ export const make = Effect.gen(function* () { .getThreadCheckpointContext(input.threadId) .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointThreadNotFoundError({ operation, - detail: `Thread '${input.threadId}' not found.`, + threadId: input.threadId, }); } @@ -115,18 +121,19 @@ export const make = Effect.gen(function* () { 0, ); if (input.toTurnCount > maxTurnCount) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointTurnRangeUnavailableError({ + operation, threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, + requestedTurnCount: input.toTurnCount, + availableTurnCount: maxTurnCount, }); } const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointWorkspacePathMissingError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, + threadId: input.threadId, }); } @@ -137,10 +144,11 @@ export const make = Effect.gen(function* () { (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, )?.checkpointRef; if (!fromCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.fromTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, + checkpoint: "from", }); } @@ -148,10 +156,11 @@ export const make = Effect.gen(function* () { (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, )?.checkpointRef; if (!toCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, + checkpoint: "to", }); } @@ -167,9 +176,9 @@ export const make = Effect.gen(function* () { const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed turn diff result does not satisfy contract schema.", + threadId: input.threadId, }); } @@ -200,9 +209,9 @@ export const make = Effect.gen(function* () { "", ); if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed full thread diff result does not satisfy contract schema.", + threadId: input.threadId, }); } return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; @@ -213,33 +222,35 @@ export const make = Effect.gen(function* () { .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointThreadNotFoundError({ operation, - detail: `Thread '${input.threadId}' not found.`, + threadId: input.threadId, }); } if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointTurnRangeUnavailableError({ + operation, threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, + requestedTurnCount: input.toTurnCount, + availableTurnCount: threadContext.value.latestCheckpointTurnCount, }); } const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointWorkspacePathMissingError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, + threadId: input.threadId, }); } if (!threadContext.value.toCheckpointRef) { - return yield* new CheckpointUnavailableError({ + return yield* new CheckpointRefUnavailableError({ + operation, threadId: input.threadId, turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, + checkpoint: "to", }); } @@ -262,9 +273,9 @@ export const make = Effect.gen(function* () { diff, ); if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ + return yield* new CheckpointDiffResultInvalidError({ operation, - detail: "Computed full thread diff result does not satisfy contract schema.", + threadId: input.threadId, }); } diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts new file mode 100644 index 000000000000..4c8b9c59cc31 --- /dev/null +++ b/apps/server/src/checkpointing/Errors.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; + +import { + CheckpointRefUnavailableError, + CheckpointTurnRangeUnavailableError, + CheckpointWorkspacePathMissingError, +} from "./Errors.ts"; + +const threadId = ThreadId.make("thread-1"); + +it("derives checkpoint messages from structured context", () => { + const range = new CheckpointTurnRangeUnavailableError({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + requestedTurnCount: 4, + availableTurnCount: 2, + }); + const checkpoint = new CheckpointRefUnavailableError({ + operation: "CheckpointDiffQuery.getTurnDiff", + threadId, + turnCount: 2, + checkpoint: "to", + }); + const workspace = new CheckpointWorkspacePathMissingError({ + operation: "CheckpointDiffQuery.getFullThreadDiff", + threadId, + }); + + expect(range.message).toBe( + "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.", + ); + expect(checkpoint.message).toBe( + "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.", + ); + expect(workspace.message).toBe( + "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.", + ); +}); diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index 6feb58d584a6..bdf409e29716 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,40 +1,94 @@ +import { NonNegativeInt, ThreadId, type VcsError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; + import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; -import type { VcsError } from "@t3tools/contracts"; -/** - * CheckpointUnavailableError - Expected checkpoint does not exist. - */ -export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( - "CheckpointUnavailableError", +export const CheckpointDiffOperation = Schema.Literals([ + "CheckpointDiffQuery.getTurnDiff", + "CheckpointDiffQuery.getFullThreadDiff", +]); +export type CheckpointDiffOperation = typeof CheckpointDiffOperation.Type; + +/** The computed result does not satisfy the checkpoint RPC contract. */ +export class CheckpointDiffResultInvalidError extends Schema.TaggedErrorClass()( + "CheckpointDiffResultInvalidError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + const result = + this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; + return `Checkpoint invariant violation in ${this.operation}: Computed ${result} result does not satisfy contract schema.`; + } +} + +/** Projection state no longer contains the requested checkpoint thread. */ +export class CheckpointThreadNotFoundError extends Schema.TaggedErrorClass()( + "CheckpointThreadNotFoundError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + return `Checkpoint invariant violation in ${this.operation}: Thread '${this.threadId}' not found.`; + } +} + +/** The checkpoint thread has no workspace path from which to compute a diff. */ +export class CheckpointWorkspacePathMissingError extends Schema.TaggedErrorClass()( + "CheckpointWorkspacePathMissingError", + { + operation: CheckpointDiffOperation, + threadId: ThreadId, + }, +) { + override get message(): string { + const diff = + this.operation === "CheckpointDiffQuery.getTurnDiff" ? "turn diff" : "full thread diff"; + return `Checkpoint invariant violation in ${this.operation}: Workspace path missing for thread '${this.threadId}' when computing ${diff}.`; + } +} + +/** The requested turn lies beyond the latest available checkpoint. */ +export class CheckpointTurnRangeUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointTurnRangeUnavailableError", { - threadId: Schema.String, - turnCount: Schema.Number, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: CheckpointDiffOperation, + threadId: ThreadId, + requestedTurnCount: NonNegativeInt, + availableTurnCount: NonNegativeInt, }, ) { override get message(): string { - return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.requestedTurnCount}: Turn diff range exceeds current turn count: requested ${this.requestedTurnCount}, current ${this.availableTurnCount}.`; } } -/** - * CheckpointInvariantError - Inconsistent provider/filesystem/catalog state. - */ -export class CheckpointInvariantError extends Schema.TaggedErrorClass()( - "CheckpointInvariantError", +/** Expected checkpoint metadata does not contain the requested Git ref. */ +export class CheckpointRefUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointRefUnavailableError", { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: CheckpointDiffOperation, + threadId: ThreadId, + turnCount: NonNegativeInt, + checkpoint: Schema.Literals(["from", "to"]), }, ) { override get message(): string { - return `Checkpoint invariant violation in ${this.operation}: ${this.detail}`; + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: Checkpoint ref is unavailable for turn ${this.turnCount}.`; } } -export type CheckpointStoreError = VcsError | CheckpointInvariantError | CheckpointUnavailableError; +export type CheckpointStoreError = VcsError; -export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError; +export type CheckpointServiceError = + | CheckpointStoreError + | ProjectionRepositoryError + | CheckpointDiffResultInvalidError + | CheckpointThreadNotFoundError + | CheckpointWorkspacePathMissingError + | CheckpointTurnRangeUnavailableError + | CheckpointRefUnavailableError; From 23ab75e84ab0680f6e71bf08dfb4b832d4bc4c87 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:19:07 -0700 Subject: [PATCH 54/80] [codex] Split project command failures (#3459) Co-authored-by: codex --- apps/server/src/cli/project.test.ts | 13 +- apps/server/src/cli/project.ts | 187 ++++++++++++++++++++-------- 2 files changed, 147 insertions(+), 53 deletions(-) diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 4d7e47ce5416..5395592c889a 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest"; import { EnvironmentInternalError } from "@t3tools/contracts"; -import { ProjectCommandError } from "./project.ts"; +import { + ProjectLiveServerDeclaredResponseError, + ProjectLiveServerRequestError, + projectCommandErrorFromLiveServerRequest, +} from "./project.ts"; it("maps declared server failures into structural project command errors", () => { const cause = new EnvironmentInternalError({ @@ -11,8 +15,9 @@ it("maps declared server failures into structural project command errors", () => traceId: "trace-123", }); - const error = ProjectCommandError.fromLiveServerRequest(cause); + const error = projectCommandErrorFromLiveServerRequest(cause); + assert.instanceOf(error, ProjectLiveServerDeclaredResponseError); assert.strictEqual(error.operation, "callLiveServer"); assert.strictEqual(error.code, "internal_error"); assert.strictEqual(error.traceId, "trace-123"); @@ -23,10 +28,10 @@ it("maps declared server failures into structural project command errors", () => it("preserves unexpected server failures without deriving the message from them", () => { const cause = new Error("credential abc123 was rejected"); - const error = ProjectCommandError.fromLiveServerRequest(cause); + const error = projectCommandErrorFromLiveServerRequest(cause); + assert.instanceOf(error, ProjectLiveServerRequestError); assert.strictEqual(error.operation, "callLiveServer"); - assert.strictEqual(error.detail, "Failed to call the running server."); assert.strictEqual(error.message, "Failed to call the running server."); assert.strictEqual(error.cause, cause); }); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 16f1f0e14d72..710d39c4c290 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -51,62 +51,148 @@ type ProjectCliDispatchCommand = Extract< >; const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); -const ProjectCommandOperation = Schema.Literals([ - "generateProjectCommandId", - "callLiveServer", - "validateProjectTitle", - "resolveProjectTarget", - "addProject", -]); -export class ProjectCommandError extends Schema.TaggedErrorClass()( - "ProjectCommandError", +export class ProjectCommandIdGenerationError extends Schema.TaggedErrorClass()( + "ProjectCommandIdGenerationError", + { + operation: Schema.Literal("generateProjectCommandId"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to generate a project command identifier."; + } +} + +export class ProjectLiveServerDeclaredResponseError extends Schema.TaggedErrorClass()( + "ProjectLiveServerDeclaredResponseError", + { + operation: Schema.Literal("callLiveServer"), + code: Schema.String, + traceId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server request failed (${this.code}, trace ${this.traceId}).`; + } +} + +export class ProjectLiveServerUndeclaredStatusError extends Schema.TaggedErrorClass()( + "ProjectLiveServerUndeclaredStatusError", + { + operation: Schema.Literal("callLiveServer"), + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server request failed with undeclared status ${this.status}.`; + } +} + +export class ProjectLiveServerRequestError extends Schema.TaggedErrorClass()( + "ProjectLiveServerRequestError", + { + operation: Schema.Literal("callLiveServer"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to call the running server."; + } +} + +export class ProjectTitleEmptyError extends Schema.TaggedErrorClass()( + "ProjectTitleEmptyError", + { + operation: Schema.Literal("validateProjectTitle"), + title: Schema.String, + }, +) { + override get message(): string { + return "Project title cannot be empty."; + } +} + +export class ProjectIdentifierEmptyError extends Schema.TaggedErrorClass()( + "ProjectIdentifierEmptyError", + { + operation: Schema.Literal("resolveProjectTarget"), + identifier: Schema.String, + }, +) { + override get message(): string { + return "Project identifier cannot be empty."; + } +} + +export class ProjectNotFoundError extends Schema.TaggedErrorClass()( + "ProjectNotFoundError", { - operation: ProjectCommandOperation, - detail: Schema.String, - code: Schema.optional(Schema.String), - traceId: Schema.optional(Schema.String), - status: Schema.optional(Schema.Number), + operation: Schema.Literal("resolveProjectTarget"), + identifier: Schema.String, + normalizedWorkspaceRoot: Schema.optional(Schema.String), + activeProjectCount: Schema.Number, cause: Schema.optional(Schema.Defect()), }, ) { - static fromLiveServerRequest(cause: unknown): ProjectCommandError { - if (isEnvironmentHttpCommonError(cause)) { - return new ProjectCommandError({ - operation: "callLiveServer", - detail: `Server request failed (${cause.code}, trace ${cause.traceId}).`, - code: cause.code, - traceId: cause.traceId, - cause, - }); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - return new ProjectCommandError({ - operation: "callLiveServer", - detail: `Server request failed with undeclared status ${cause.response.status}.`, - status: cause.response.status, - cause, - }); - } - return new ProjectCommandError({ + override get message(): string { + return `No active project found for '${this.identifier}'.`; + } +} + +export class ProjectAlreadyExistsError extends Schema.TaggedErrorClass()( + "ProjectAlreadyExistsError", + { + operation: Schema.Literal("addProject"), + projectId: ProjectId, + workspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `An active project already exists for '${this.workspaceRoot}'.`; + } +} + +export const ProjectCommandError = Schema.Union([ + ProjectCommandIdGenerationError, + ProjectLiveServerDeclaredResponseError, + ProjectLiveServerUndeclaredStatusError, + ProjectLiveServerRequestError, + ProjectTitleEmptyError, + ProjectIdentifierEmptyError, + ProjectNotFoundError, + ProjectAlreadyExistsError, +]); +export type ProjectCommandError = typeof ProjectCommandError.Type; + +export function projectCommandErrorFromLiveServerRequest(cause: unknown): ProjectCommandError { + if (isEnvironmentHttpCommonError(cause)) { + return new ProjectLiveServerDeclaredResponseError({ operation: "callLiveServer", - detail: "Failed to call the running server.", + code: cause.code, + traceId: cause.traceId, cause, }); } - - override get message(): string { - return this.detail; + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + return new ProjectLiveServerUndeclaredStatusError({ + operation: "callLiveServer", + status: cause.response.status, + cause, + }); } + + return new ProjectLiveServerRequestError({ operation: "callLiveServer", cause }); } const projectCommandUuid = Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.mapError( (cause) => - new ProjectCommandError({ + new ProjectCommandIdGenerationError({ operation: "generateProjectCommandId", - detail: "Failed to generate a project command identifier.", cause, }), ), @@ -158,9 +244,9 @@ const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( if (trimmed.length > 0) { return trimmed; } - return yield* new ProjectCommandError({ + return yield* new ProjectTitleEmptyError({ operation: "validateProjectTitle", - detail: "Project title cannot be empty.", + title: explicitTitle, }); } @@ -175,9 +261,9 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }) { const trimmedIdentifier = input.identifier.trim(); if (trimmedIdentifier.length === 0) { - return yield* new ProjectCommandError({ + return yield* new ProjectIdentifierEmptyError({ operation: "resolveProjectTarget", - detail: "Project identifier cannot be empty.", + identifier: input.identifier, }); } @@ -204,9 +290,11 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( const resolved = exactWorkspaceMatch; if (!resolved) { - return yield* new ProjectCommandError({ + return yield* new ProjectNotFoundError({ operation: "resolveProjectTarget", - detail: `No active project found for '${trimmedIdentifier}'.`, + identifier: trimmedIdentifier, + activeProjectCount: activeProjects.length, + ...(normalizedWorkspaceRoot === null ? {} : { normalizedWorkspaceRoot }), ...(normalizedWorkspaceRootResult._tag === "Failure" ? { cause: normalizedWorkspaceRootResult.failure } : {}), @@ -228,7 +316,7 @@ const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => }); }).pipe( withProjectCliLiveServerTimeout, - Effect.mapError(ProjectCommandError.fromLiveServerRequest), + Effect.mapError(projectCommandErrorFromLiveServerRequest), ); const dispatchLiveOrchestrationCommand = ( @@ -244,7 +332,7 @@ const dispatchLiveOrchestrationCommand = ( } as Parameters[0]); }).pipe( withProjectCliLiveServerTimeout, - Effect.mapError(ProjectCommandError.fromLiveServerRequest), + Effect.mapError(projectCommandErrorFromLiveServerRequest), ); const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { @@ -376,9 +464,10 @@ const projectAddCommand = Command.make("add", { (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, ); if (existingProject) { - return yield* new ProjectCommandError({ + return yield* new ProjectAlreadyExistsError({ operation: "addProject", - detail: `An active project already exists for '${workspaceRoot}'.`, + projectId: existingProject.id, + workspaceRoot, }); } From 8d4a8b4f3179a054b545b252a1b4c005e9da7875 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:19:12 -0700 Subject: [PATCH 55/80] [codex] Structure preview capability errors (#3454) Co-authored-by: codex --- .../src/mcp/McpInvocationContext.test.ts | 39 +++++++++++++++++++ apps/server/src/mcp/McpInvocationContext.ts | 14 +++++-- packages/contracts/src/previewAutomation.ts | 14 ++++++- 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/mcp/McpInvocationContext.test.ts diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts new file mode 100644 index 000000000000..39c686890473 --- /dev/null +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PreviewAutomationUnavailableError, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as McpInvocationContext from "./McpInvocationContext.ts"; + +it.effect("reports the scoped credential context when preview capability is unavailable", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(), + issuedAt: 1, + expiresAt: 2, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + + expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toMatchObject({ + capability: "preview", + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }); + expect(error.message).toBe("MCP credential does not grant the preview capability."); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 0d3f84df42ce..b13bf2d312e8 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,5 +1,9 @@ -import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; +import { + type EnvironmentId, + PreviewAutomationUnavailableError, + type ProviderInstanceId, + type ThreadId, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -26,7 +30,11 @@ export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* const invocation = yield* McpInvocationContext; if (!invocation.capabilities.has(capability)) { return yield* new PreviewAutomationUnavailableError({ - message: `MCP credential does not grant the ${capability} capability.`, + capability, + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, }); } return invocation; diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index d6b9f59ae8d8..118fb8927370 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -453,8 +453,18 @@ export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass()( "PreviewAutomationUnavailableError", - { message: Schema.String }, -) {} + { + capability: Schema.Literal("preview"), + environmentId: EnvironmentId, + threadId: ThreadId, + providerSessionId: TrimmedNonEmptyString, + providerInstanceId: ProviderInstanceId, + }, +) { + override get message(): string { + return `MCP credential does not grant the ${this.capability} capability.`; + } +} const PreviewAutomationScopeErrorFields = { operation: PreviewAutomationOperation, From 6424197a19a28d5992be555633a6e37fd0a58573 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:30:19 -0700 Subject: [PATCH 56/80] [codex] Structure unroutable app-server messages (#3463) Co-authored-by: codex --- .../effect-codex-app-server/src/errors.ts | 33 ++++++++++++++++++- .../src/protocol.test.ts | 30 +++++++++++++++++ .../effect-codex-app-server/src/protocol.ts | 5 +-- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 2559bba618c7..3826a0992293 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -82,6 +82,11 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { return typeof payload; }; +const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; + +export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); +export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; + export interface CodexAppServerRequestDiagnostics { readonly method?: string; readonly requestId?: string; @@ -157,7 +162,8 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass field in message); + const method = + "method" in message && typeof message.method === "string" ? message.method : undefined; + const requestId = + "id" in message && (typeof message.id === "string" || typeof message.id === "number") + ? String(message.id) + : undefined; + return new CodexAppServerProtocolParseError({ + operation: "route-wire-message", + ...diagnostics, + presentFields, + ...(method === undefined ? {} : { method }), + ...(requestId === undefined ? {} : { requestId }), + }); + } } export class CodexAppServerTransportError extends Schema.TaggedErrorClass()( diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index f387ca382be3..0ed81b3b9e6b 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -311,6 +311,36 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("describes unroutable messages with safe structural diagnostics", () => + Effect.gen(function* () { + const secret = "codex-unroutable-secret-sentinel"; + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: true, method: "thread/start", params: { token: secret } }), + ); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, CodexError.CodexAppServerProtocolParseError); + assert.deepInclude(error, { + operation: "route-wire-message", + method: "thread/start", + payloadKind: "object", + presentFields: ["id", "method", "params"], + }); + assert.isUndefined(error.requestId); + assert.notProperty(error, "detail"); + assert.notProperty(error, "cause"); + assert.notInclude(error.message, secret); + }), + ); + it.effect("classifies an input stream ending without inventing a cause", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index fbf173cbc5e6..825c59b9b2c5 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -310,10 +310,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return handleResponse(message); } return Effect.fail( - new CodexError.CodexAppServerProtocolParseError({ - detail: "Received protocol message in an unknown shape", - operation: "route-wire-message", - }), + CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), ); }; From 1a59277dc84105e2131c2bc828deb51c0b12d7f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:41:25 -0700 Subject: [PATCH 57/80] [codex] Structure GitLab CLI failures (#3458) Co-authored-by: codex --- .../src/sourceControl/GitLabCli.test.ts | 31 +- apps/server/src/sourceControl/GitLabCli.ts | 389 ++++++++++++------ .../GitLabSourceControlProvider.test.ts | 3 +- 3 files changed, 297 insertions(+), 126 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 792e3a82b139..87621e5c8bcf 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -315,10 +315,11 @@ layer("GitLabCli.layer", (it) => { Effect.gen(function* () { const cause = new VcsProcessExitError({ operation: "GitLabCli.execute", - command: "glab mr view 4888", + command: "glab", cwd: "/repo", exitCode: 1, detail: "GET 404 merge request not found", + failureKind: "not-found", }); mockedRun.mockReturnValueOnce(Effect.fail(cause)); @@ -330,11 +331,37 @@ layer("GitLabCli.layer", (it) => { }); }).pipe(Effect.flip); - assert.equal(error.message.includes("Merge request not found"), true); + assert.equal(error.message.includes("Merge request 4888 was not found"), true); + assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); assert.strictEqual(error.command, "glab"); assert.strictEqual(error.cwd, "/repo"); assert.strictEqual(error.cause, cause); assert.equal(error.message.includes(cause.detail), false); }), ); + + it.effect("keeps non-merge-request not-found failures generic", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 project not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "missing/project", + }); + }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitLabCliCommandError"); + assert.strictEqual(error.cause, cause); + }), + ); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index b34e72ffc953..3e3bbe742c16 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -21,13 +22,56 @@ import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { - operation: Schema.String, - command: Schema.String, +const gitLabCliExecutionErrorContext = { + operation: Schema.Literal("execute"), + command: Schema.Literal("glab"), cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { + cause: Schema.Defect(), +}; + +const gitLabCliDecodeErrorContext = { + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +export class GitLabCliUnavailableError extends Schema.TaggedErrorClass()( + "GitLabCliUnavailableError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI (`glab`) is required but not available on PATH."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitLabCliAuthenticationError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestNotFoundError", + { + ...gitLabCliExecutionErrorContext, + reference: Schema.String, + }, +) { + get detail(): string { + return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; + } + override get message(): string { return `GitLab CLI failed in ${this.operation}: ${this.detail}`; } @@ -37,52 +81,145 @@ export class GitLabCliError extends Schema.TaggedErrorClass()("G readonly operation: "execute"; readonly command: "glab"; readonly cwd: string; + readonly reference: string; }, - error: VcsError | unknown, + error: VcsError, ): GitLabCliError { - const lower = errorText(error).toLowerCase(); - - if (lower.includes("command not found: glab") || isVcsProcessSpawnError(error)) { - return new GitLabCliError({ - ...context, - detail: "GitLab CLI (`glab`) is required but not available on PATH.", - cause: error, - }); + if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { + return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); } - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("glab auth login") || - lower.includes("token") - ) { - return new GitLabCliError({ - ...context, - detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - cause: error, - }); - } + return GitLabCliCommandError.fromVcsError( + { + operation: context.operation, + command: context.command, + cwd: context.cwd, + }, + error, + ); + } +} - if ( - lower.includes("merge request not found") || - lower.includes("not found") || - lower.includes("404") - ) { - return new GitLabCliError({ - ...context, - detail: "Merge request not found. Check the MR number or URL and try again.", - cause: error, - }); - } +export class GitLabCliCommandError extends Schema.TaggedErrorClass()( + "GitLabCliCommandError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI command failed."; + } - return new GitLabCliError({ - ...context, - detail: "GitLab CLI command failed.", - cause: error, + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + }, + error: VcsError, + ): GitLabCliError { + return Match.valueTags(error, { + VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), + VcsProcessExitError: (cause) => { + switch (cause.failureKind) { + case "authentication": + return new GitLabCliAuthenticationError({ ...context, cause }); + case "not-found": + case "command-failed": + case undefined: + return new GitLabCliCommandError({ ...context, cause }); + } + }, + VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); } } +export class GitLabMergeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestListDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("listMergeRequests"), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid MR list JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestDecodeError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("getMergeRequest"), + reference: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid merge request JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitLabRepositoryDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), + repository: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid repository JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass()( + "GitLabNamespaceDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("createRepository"), + namespacePath: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid namespace JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const GitLabCliError = Schema.Union([ + GitLabCliUnavailableError, + GitLabCliAuthenticationError, + GitLabMergeRequestNotFoundError, + GitLabCliCommandError, + GitLabMergeRequestListDecodeError, + GitLabMergeRequestDecodeError, + GitLabRepositoryDecodeError, + GitLabNamespaceDecodeError, +]); +export type GitLabCliError = typeof GitLabCliError.Type; +export const isGitLabCliError = Schema.is(GitLabCliError); + export interface GitLabMergeRequestSummary { readonly number: number; readonly title: string; @@ -102,17 +239,6 @@ export interface GitLabRepositoryCloneUrls { readonly sshUrl: string; } -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - export class GitLabCli extends Context.Service< GitLabCli, { @@ -168,15 +294,6 @@ export class GitLabCli extends Context.Service< } >()("t3/sourceControl/GitLabCli") {} -function isVcsProcessSpawnError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "VcsProcessSpawnError" - ); -} - const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ path_with_namespace: TrimmedNonEmptyString, web_url: TrimmedNonEmptyString, @@ -192,6 +309,14 @@ const RawGitLabNamespaceSchema = Schema.Struct({ id: Schema.Number, }); +const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), +); +const decodeGitLabDefaultBranch = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabDefaultBranchSchema), +); +const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitLabRepositoryCloneUrls { @@ -202,27 +327,6 @@ function normalizeRepositoryCloneUrls( }; } -function decodeGitLabJson( - raw: string, - schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, - cwd: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitLabCliError({ - operation, - command: "glab", - cwd, - detail: invalidDetail, - cause: error, - }), - ), - ); -} - function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { switch (state) { case "open": @@ -283,7 +387,10 @@ function parseRepositoryPath(repository: string): { export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitLabCli["Service"]["execute"] = (input) => + const run = ( + input: Parameters[0], + mapError: (error: VcsError) => GitLabCliError, + ) => process .run({ operation: "GitLabCli.execute", @@ -292,14 +399,32 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe( - Effect.mapError((error) => - GitLabCliError.fromVcsError( - { operation: "execute", command: "glab", cwd: input.cwd }, - error, - ), - ), - ); + .pipe(Effect.mapError(mapError)); + + const execute: GitLabCli["Service"]["execute"] = (input) => + run(input, (error) => + GitLabCliCommandError.fromVcsError( + { operation: "execute", command: "glab", cwd: input.cwd }, + error, + ), + ); + + const executeMergeRequest = (input: { + readonly cwd: string; + readonly reference: string; + readonly args: ReadonlyArray; + }) => + run(input, (error) => + GitLabMergeRequestNotFoundError.fromVcsError( + { + operation: "execute", + command: "glab", + cwd: input.cwd, + reference: input.reference, + }, + error, + ), + ); return GitLabCli.of({ execute, @@ -326,11 +451,10 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestListDecodeError({ operation: "listMergeRequests", command: "glab", cwd: input.cwd, - detail: "GitLab CLI returned invalid MR list JSON.", cause: decoded.failure, }), ); @@ -342,8 +466,9 @@ export const make = Effect.gen(function* () { ), ), getMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "view", input.reference, "--output", "json"], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -352,11 +477,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitLabCliError({ + new GitLabMergeRequestDecodeError({ operation: "getMergeRequest", command: "glab", cwd: input.cwd, - detail: "GitLab CLI returned invalid merge request JSON.", + reference: input.reference, cause: decoded.failure, }), ); @@ -374,12 +499,17 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getRepositoryCloneUrls", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -393,12 +523,17 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabNamespaceSchema, - "createRepository", - "GitLab CLI returned invalid namespace JSON.", - input.cwd, + decodeGitLabNamespace(raw).pipe( + Effect.mapError( + (cause) => + new GitLabNamespaceDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + namespacePath, + cause, + }), + ), ), ), Effect.map((namespace) => namespace.id), @@ -428,12 +563,17 @@ export const make = Effect.gen(function* () { ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabRepositoryCloneUrlsSchema, - "createRepository", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), @@ -467,19 +607,24 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitLabJson( - raw, - RawGitLabDefaultBranchSchema, - "getDefaultBranch", - "GitLab CLI returned invalid repository JSON.", - input.cwd, + decodeGitLabDefaultBranch(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getDefaultBranch", + command: "glab", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map((value) => value.default_branch ?? null), ), checkoutMergeRequest: (input) => - execute({ + executeMergeRequest({ cwd: input.cwd, + reference: input.reference, args: ["mr", "checkout", input.reference], }).pipe(Effect.asVoid), }); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 6ab3f23b150d..0d06e0665214 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -54,11 +54,10 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = it.effect("adds repository context while retaining GitLab CLI causes", () => Effect.gen(function* () { - const cause = new GitLabCli.GitLabCliError({ + const cause = new GitLabCli.GitLabCliCommandError({ operation: "execute", command: "glab", cwd: "/repo", - detail: "GitLab CLI command failed.", cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({ From a3dadc04bf38b49bc8476a8e6c59bda7dc81d2e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:47:12 -0700 Subject: [PATCH 58/80] [codex] Structure Bitbucket API errors (#3457) Co-authored-by: codex --- .../src/sourceControl/BitbucketApi.test.ts | 62 ++++- apps/server/src/sourceControl/BitbucketApi.ts | 217 ++++++++++++++---- .../BitbucketSourceControlProvider.test.ts | 9 +- 3 files changed, 239 insertions(+), 49 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index e4a7649e74a9..5a9759ace0b7 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -533,8 +533,8 @@ it.effect("preserves the HTTP client failure without deriving the domain message }), ); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.detail, "Failed to send the Bitbucket request."); assert.strictEqual( error.message, "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", @@ -544,6 +544,61 @@ it.effect("preserves the HTTP client failure without deriving the domain message }).pipe(Effect.provide(layer)); }); +it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { + const responseBody = '{"error":{"message":"credential=secret-value"}}'; + const { layer } = makeLayer({ + response: () => new Response(responseBody, { status: 403 }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .checkoutPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 403); + assert.strictEqual(error.responseBodyLength, responseBody.length); + assert.notProperty(error, "responseBody"); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.", + ); + assert.notInclude(error.message, "secret-value"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { + const cause = new Error("response stream failed"); + const { layer } = makeLayer({ + response: () => + new Response( + new ReadableStream({ + start: (controller) => controller.error(cause), + }), + { status: 502 }, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .getPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 502); + assert.instanceOf(error.cause, HttpClientError.HttpClientError); + assert.strictEqual(error.cause.cause, cause); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", + ); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -630,8 +685,9 @@ it.effect("preserves Git checkout failures without deriving the domain message f }), ); - assert.strictEqual(error.operation, "checkoutPullRequest"); - assert.strictEqual(error.detail, "Failed to check out the Bitbucket pull request."); + assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "42"); assert.strictEqual( error.message, "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 9a678ab44dc0..f7d7f6671a46 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, @@ -36,20 +37,156 @@ const BitbucketApiEnvConfig = Config.all({ apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), }); -export class BitbucketApiError extends Schema.TaggedErrorClass()( - "BitbucketApiError", +const BitbucketApiOperation = Schema.Literals([ + "resolveRepository", + "getRepository", + "getBranchingModel", + "getPullRequest", + "listPullRequests", + "createRepository", + "createPullRequest", + "probeAuth", + "checkoutPullRequest", +]); +type BitbucketApiOperation = typeof BitbucketApiOperation.Type; + +export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryLocatorError", { - operation: Schema.String, - detail: Schema.String, - status: Schema.optional(Schema.Number), - cause: Schema.optional(Schema.Defect()), + repository: Schema.String, }, ) { override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository."; } } -const isBitbucketApiError = Schema.is(BitbucketApiError); + +export class BitbucketRequestError extends Schema.TaggedErrorClass()( + "BitbucketRequestError", + { + operation: BitbucketApiOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Failed to send the Bitbucket request.`; + } +} + +export class BitbucketResponseError extends Schema.TaggedErrorClass()( + "BitbucketResponseError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + responseBodyLength: NonNegativeInt, + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketResponseBodyReadError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`; + } +} + +export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass()( + "BitbucketResponseDecodeError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: Bitbucket returned invalid JSON for the requested resource.`; + } +} + +export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryVcsResolveError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to resolve VCS repository for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemotesListError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: Failed to list remotes for ${this.cwd}.`; + } +} + +export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryRemoteNotFoundError", + { + cwd: Schema.String, + }, +) { + override get message(): string { + return `Bitbucket API failed in resolveRepository: No Bitbucket repository remote was detected for ${this.cwd}.`; + } +} + +export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass()( + "BitbucketPullRequestBodyReadError", + { + cwd: Schema.String, + bodyFile: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Bitbucket API failed in createPullRequest: Failed to read pull request body file ${this.bodyFile}.`; + } +} + +export class BitbucketCheckoutError extends Schema.TaggedErrorClass()( + "BitbucketCheckoutError", + { + cwd: Schema.String, + reference: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request."; + } +} + +export const BitbucketApiError = Schema.Union([ + BitbucketRepositoryLocatorError, + BitbucketRequestError, + BitbucketResponseError, + BitbucketResponseBodyReadError, + BitbucketResponseDecodeError, + BitbucketRepositoryVcsResolveError, + BitbucketRepositoryRemotesListError, + BitbucketRepositoryRemoteNotFoundError, + BitbucketPullRequestBodyReadError, + BitbucketCheckoutError, +]); +export type BitbucketApiError = typeof BitbucketApiError.Type; +export const isBitbucketApiError = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({ full_name: TrimmedNonEmptyString, @@ -209,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator } function requireRepositoryLocator( - operation: string, repository: string, ): Effect.Effect { const locator = parseBitbucketRepositorySlug(repository); return locator ? Effect.succeed(locator) : Effect.fail( - new BitbucketApiError({ - operation, - detail: "Bitbucket repositories must be specified as workspace/repository.", + new BitbucketRepositoryLocatorError({ + repository, }), ); } @@ -339,20 +474,24 @@ function authFromConfig( } function responseError( - operation: string, + operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { return response.text.pipe( - Effect.orElseSucceed(() => ""), + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation, + status: response.status, + cause, + }), + ), Effect.flatMap((body) => Effect.fail( - new BitbucketApiError({ + new BitbucketResponseError({ operation, status: response.status, - detail: - body.trim().length > 0 - ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` - : `Bitbucket returned HTTP ${response.status}.`, + responseBodyLength: body.length, }), ), ), @@ -379,7 +518,7 @@ export const make = Effect.gen(function* () { }; const decodeResponse = ( - operation: string, + operation: BitbucketApiOperation, schema: S, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect => @@ -388,9 +527,9 @@ export const make = Effect.gen(function* () { HttpClientResponse.schemaBodyJson(schema)(success).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketResponseDecodeError({ operation, - detail: "Bitbucket returned invalid JSON for the requested resource.", + status: success.status, cause, }), ), @@ -399,16 +538,15 @@ export const make = Effect.gen(function* () { })(response); const executeJson = ( - operation: string, + operation: BitbucketApiOperation, request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketRequestError({ operation, - detail: "Failed to send the Bitbucket request.", cause, }), ), @@ -433,9 +571,8 @@ export const make = Effect.gen(function* () { const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to resolve VCS repository for ${input.cwd}.`, + new BitbucketRepositoryVcsResolveError({ + cwd: input.cwd, cause, }), ), @@ -443,9 +580,8 @@ export const make = Effect.gen(function* () { const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to list remotes for ${input.cwd}.`, + new BitbucketRepositoryRemotesListError({ + cwd: input.cwd, cause, }), ), @@ -457,9 +593,8 @@ export const make = Effect.gen(function* () { if (parsed) return parsed; } - return yield* new BitbucketApiError({ - operation: "resolveRepository", - detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + return yield* new BitbucketRepositoryRemoteNotFoundError({ + cwd: input.cwd, }); }); @@ -600,7 +735,7 @@ export const make = Effect.gen(function* () { getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => - requireRepositoryLocator("createRepository", input.repository).pipe( + requireRepositoryLocator(input.repository).pipe( Effect.flatMap((repository) => executeJson( "createRepository", @@ -625,9 +760,9 @@ export const make = Effect.gen(function* () { const description = yield* fileSystem.readFileString(input.bodyFile).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "createPullRequest", - detail: `Failed to read pull request body file ${input.bodyFile}.`, + new BitbucketPullRequestBodyReadError({ + cwd: input.cwd, + bodyFile: input.bodyFile, cause, }), ), @@ -743,9 +878,9 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => isBitbucketApiError(cause) ? cause - : new BitbucketApiError({ - operation: "checkoutPullRequest", - detail: "Failed to check out the Bitbucket pull request.", + : new BitbucketCheckoutError({ + cwd: input.cwd, + reference: input.reference, cause, }), ), diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 75ac877cd432..eeb4c8fbdd2a 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -53,11 +53,10 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( it.effect("adds repository context while retaining Bitbucket API causes", () => Effect.gen(function* () { - const cause = new BitbucketApi.BitbucketApiError({ + const upstreamCause = new Error("raw upstream failure"); + const cause = new BitbucketApi.BitbucketRequestError({ operation: "getRepository", - detail: "upstream detail that should remain in the cause", - status: 503, - cause: new Error("raw upstream failure"), + cause: upstreamCause, }); const provider = yield* makeProvider({ getRepositoryCloneUrls: () => Effect.fail(cause), @@ -86,7 +85,7 @@ it.effect("adds repository context while retaining Bitbucket API causes", () => }, ); assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.detail), false); + assert.equal(error.message.includes(upstreamCause.message), false); }), ); From 2fbb0565d7976cbb338f5593a566ff53c6165419 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:08 -0700 Subject: [PATCH 59/80] [codex] Structure workspace search cleanup failures (#3465) Co-authored-by: codex --- apps/server/src/workspace/WorkspaceEntries.ts | 27 ++++++++++------ .../workspace/WorkspaceSearchIndex.test.ts | 31 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 17 +++++++++- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 81fb735ea2eb..7501cbe0eab2 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -151,20 +151,29 @@ export const make = Effect.gen(function* () { if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { return; } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + cause, + }); + yield* workspaceSearchIndexes.invalidate(normalizedCwd); + }); yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; yield* searchIndex.refresh(); }).pipe( Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.catch((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - cause, - }); - yield* workspaceSearchIndexes.invalidate(normalizedCwd); - }), - ), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), ); }, ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 41ea90b97355..9b7ed4e2453f 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,6 +1,8 @@ import { FileFinder } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -49,6 +51,35 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); +it.effect("preserves FileFinder destroy failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("native destroy failed"); + const finder = { + destroy: vi.fn(() => { + throw cause; + }), + isScanning: vi.fn(() => false), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe( + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed); + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexDestroyFailed", + cwd: "/workspace/project", + cause, + }); + } + }), +); + it.effect("preserves search and refresh failures with operation context", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 2b043e05c0e6..db4d46851e7b 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -71,6 +71,18 @@ export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexDestroyFailed", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to destroy the workspace search index for '${this.cwd}'.`; + } +} + export type WorkspaceSearchIndexError = | WorkspaceSearchIndexCreateFailed | WorkspaceSearchIndexScanTimedOut @@ -201,7 +213,10 @@ const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unkn export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => - Effect.sync(() => finder.destroy()), + Effect.try({ + try: () => finder.destroy(), + catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), + }).pipe(Effect.orDie), ); yield* waitForScan( cwd, From cdd03591efb09baf06299e3764ddbd01c313a936 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:13 -0700 Subject: [PATCH 60/80] [codex] Structure release metadata failures (#3467) Co-authored-by: codex --- scripts/resolve-nightly-release.test.ts | 51 +++++++++ scripts/resolve-nightly-release.ts | 38 ++++++- scripts/resolve-previous-release-tag.test.ts | 93 +++++++++++++++- scripts/resolve-previous-release-tag.ts | 110 +++++++++++++++++-- 4 files changed, 278 insertions(+), 14 deletions(-) diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index ecc94c57f599..18d6a2a2ed73 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,7 +1,12 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { + readDesktopBaseVersion, resolveNightlyBaseVersion, resolveNightlyReleaseMetadata, resolveNightlyTargetVersion, @@ -43,3 +48,49 @@ it("derives nightly metadata including the short commit sha in the release name" }, ); }); + +it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { + it.effect("preserves desktop package read context and its platform cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "resolve-nightly-release-read-", + }); + const packageJsonPath = path.join(rootDir, "apps/desktop/package.json"); + + const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip); + + if (error._tag !== "NightlyReleaseDesktopPackageError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "read"); + assert.equal(error.packageJsonPath, packageJsonPath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.notInclude(error.message, String((error.cause as Error).message)); + }), + ); + + it.effect("preserves desktop package decode context and its schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = yield* fs.makeTempDirectoryScoped({ + prefix: "resolve-nightly-release-decode-", + }); + const packageJsonPath = path.join(rootDir, "apps/desktop/package.json"); + yield* fs.makeDirectory(path.dirname(packageJsonPath), { recursive: true }); + yield* fs.writeFileString(packageJsonPath, "{"); + + const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip); + + if (error._tag !== "NightlyReleaseDesktopPackageError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "decode"); + assert.equal(error.packageJsonPath, packageJsonPath); + assert.ok(error.cause !== undefined); + assert.notInclude(error.message, String((error.cause as Error).message)); + }), + ); +}); diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index ae6bc323c67b..adad8c6f4f87 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -40,6 +40,19 @@ export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass()( + "NightlyReleaseDesktopPackageError", + { + operation: Schema.Literals(["read", "decode"]), + packageJsonPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} desktop package metadata at ${this.packageJsonPath}.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -77,16 +90,33 @@ export const resolveNightlyReleaseMetadata = ( }; }; -const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( +export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( rootDir: string | undefined, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); - const packageJson = yield* fs - .readFileString(packageJsonPath) - .pipe(Effect.flatMap(decodeDesktopPackageJson)); + const packageJsonSource = yield* fs.readFileString(packageJsonPath).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseDesktopPackageError({ + operation: "read", + packageJsonPath, + cause, + }), + ), + ); + const packageJson = yield* decodeDesktopPackageJson(packageJsonSource).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseDesktopPackageError({ + operation: "decode", + packageJsonPath, + cause, + }), + ), + ); return yield* resolveNightlyTargetVersion(packageJson.version); }); diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts index ecf564c005e0..a9c4832c26ad 100644 --- a/scripts/resolve-previous-release-tag.test.ts +++ b/scripts/resolve-previous-release-tag.test.ts @@ -1,7 +1,33 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; +import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; + +const encoder = new TextEncoder(); + +function mockHandle(options: { + readonly exitCode: number; + readonly stdout?: string; + readonly stderr?: string; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(options.stdout ?? "")), + stderr: Stream.make(encoder.encode(options.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} it.effect("selects the latest earlier stable tag and ignores nightlies", () => Effect.gen(function* () { @@ -37,3 +63,68 @@ it.effect("reports the invalid tag with its release channel", () => assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'."); }), ); + +it.effect("preserves git tag spawn context and the exact platform cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "git was not found", + }); + + return Effect.gen(function* () { + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.executable, "git"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.notProperty(error, "args"); + assert.notInclude(error.message, cause.message); + }); +}); + +it.effect("reports git tag non-zero exits without manufacturing a cause", () => + Effect.gen(function* () { + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + exitCode: 17, + stdout: "v1.2.3\n", + stderr: "fatal: repository unavailable\n", + }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessExitError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.executable, "git"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo"); + assert.equal(error.exitCode, 17); + assert.equal(error.stdoutLength, 7); + assert.equal(error.stderrLength, 30); + assert.notProperty(error, "cause"); + assert.notProperty(error, "stdout"); + assert.notProperty(error, "stderr"); + }), +); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 8b1f1fc96480..83b6e65d1e13 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -2,7 +2,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Array from "effect/Array"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -27,6 +26,39 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "ReleaseTagListProcessError", + { + ...releaseTagListProcessContext, + operation: Schema.Literals(["spawn", "communicate", "wait-for-exit"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to list release tags during process operation "${this.operation}".`; + } +} + +export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass()( + "ReleaseTagListProcessExitError", + { + ...releaseTagListProcessContext, + exitCode: Schema.Number, + stdoutLength: Schema.Number, + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `Release tag listing exited with code ${this.exitCode}.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -172,20 +204,80 @@ export const resolvePreviousReleaseTag = ( return candidates[0]?.tag; }); -const listGitTags = Effect.fn("listGitTags")(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"])); - const tags = yield* child.stdout.pipe( +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), - Effect.map(String.split(/\r?\n/)), - Effect.map(Array.map(String.trim)), - Effect.map(Array.filter(String.isNonEmpty)), ); - return tags; + +export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd()) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const args = ["tag", "--list"] as const; + const context = { + executable: "git", + argumentCount: args.length, + cwd, + } as const; + const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "spawn", + cause, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "communicate", + cause, + }), + ), + ), + collectStreamAsString(child.stderr).pipe( + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "communicate", + cause, + }), + ), + ), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new ReleaseTagListProcessError({ + ...context, + operation: "wait-for-exit", + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + return yield* new ReleaseTagListProcessExitError({ + ...context, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + + return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty); }); const writeOutput = Effect.fn("writeOutput")(function* ( From 6fc79737c3854344d05c1ac3d0dd10e143a19fa9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:16 -0700 Subject: [PATCH 61/80] [codex] Structure mobile native static-check failures (#3464) Co-authored-by: codex --- scripts/mobile-native-static-check.test.ts | 148 +++++++++++++++++++-- scripts/mobile-native-static-check.ts | 120 ++++++++++++++--- 2 files changed, 238 insertions(+), 30 deletions(-) diff --git a/scripts/mobile-native-static-check.test.ts b/scripts/mobile-native-static-check.test.ts index 9671ffbbc9fe..7393b4bd9c82 100644 --- a/scripts/mobile-native-static-check.test.ts +++ b/scripts/mobile-native-static-check.test.ts @@ -1,18 +1,142 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { NativeStaticCheckCommandError } from "./mobile-native-static-check.ts"; +import { collectSources, runCommand } from "./mobile-native-static-check.ts"; -it("describes failed native static-analysis commands structurally", () => { - const error = new NativeStaticCheckCommandError({ - command: "swiftlint", - args: ["lint", "--strict"], - cwd: "/repo/apps/mobile", - exitCode: 2, +const processHandle = ( + exitCode: Effect.Effect, +) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode, + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, }); - assert.equal(error.command, "swiftlint"); - assert.deepStrictEqual(error.args, ["lint", "--strict"]); - assert.equal(error.cwd, "/repo/apps/mobile"); - assert.equal(error.exitCode, 2); - assert.equal(error.message, "Native static check command 'swiftlint' exited with code 2."); +const provideSpawner = (spawn: ChildProcessSpawner.ChildProcessSpawner["Service"]["spawn"]) => + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(spawn)); + +const runSwiftLint = runCommand("swiftlint", ["lint", "--strict"], "/repo/apps/mobile").pipe( + Effect.provideService(HostProcess.HostProcessPlatform, "linux"), +); + +it.layer(NodeServices.layer)("mobile native source discovery", (it) => { + it.effect("preserves the failed discovery operation, path, and exact cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "mobile-native-static-check-" }); + const missingDirectory = path.join(root, "missing"); + + const error = yield* collectSources(missingDirectory, root).pipe(Effect.flip); + + assert.equal(error._tag, "NativeStaticCheckSourceDiscoveryError"); + assert.equal(error.operation, "read-directory"); + assert.equal(error.path, missingDirectory); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, "Native source discovery operation 'read-directory' failed."); + }), + ); +}); + +it.effect("preserves process spawn context and the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "swiftlint was not found", + }); + + return Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide(provideSpawner(() => Effect.fail(cause))), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.equal( + error.message, + "Native static check process operation 'spawn' failed for command 'swiftlint'.", + ); + assert.notProperty(error, "args"); + }); }); + +it.effect("preserves process wait context and the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + description: "status unavailable", + }); + + return Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide(provideSpawner(() => Effect.succeed(processHandle(Effect.fail(cause))))), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "wait-for-exit"); + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.equal( + error.message, + "Native static check process operation 'wait-for-exit' failed for command 'swiftlint'.", + ); + assert.notProperty(error, "args"); + }); +}); + +it.effect("reports non-zero exits without manufacturing a cause", () => + Effect.gen(function* () { + const error = yield* runSwiftLint.pipe( + Effect.provide( + provideSpawner(() => + Effect.succeed(processHandle(Effect.succeed(ChildProcessSpawner.ExitCode(2)))), + ), + ), + Effect.flip, + ); + + if (error._tag !== "NativeStaticCheckCommandError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.command, "swiftlint"); + assert.equal(error.argumentCount, 2); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.shell, false); + assert.equal(error.exitCode, 2); + assert.notProperty(error, "cause"); + assert.notProperty(error, "args"); + }), +); diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index cbdf4be2bd07..8239a092bc22 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -8,7 +8,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { Command } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -18,12 +17,44 @@ interface NativeStaticTool { readonly installHint: string; } +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +export class NativeStaticCheckSourceDiscoveryError extends Schema.TaggedErrorClass()( + "NativeStaticCheckSourceDiscoveryError", + { + operation: Schema.Literals(["resolve-root", "read-directory", "stat-entry"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Native source discovery operation '${this.operation}' failed.`; + } +} + +export class NativeStaticCheckProcessError extends Schema.TaggedErrorClass()( + "NativeStaticCheckProcessError", + { + operation: Schema.Literals(["spawn", "wait-for-exit"]), + command: Schema.String, + argumentCount: NonNegativeInt, + cwd: Schema.String, + shell: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Native static check process operation '${this.operation}' failed for command '${this.command}'.`; + } +} + export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass()( "NativeStaticCheckCommandError", { command: Schema.String, - args: Schema.Array(Schema.String), + argumentCount: NonNegativeInt, cwd: Schema.String, + shell: Schema.Boolean, exitCode: Schema.Int, }, ) { @@ -59,8 +90,17 @@ const excludedDirectories = new Set([ ]); const generatedNativeProjectDirectories = new Set(["android", "ios"]); +const mobileAppRootUrl = new URL("../apps/mobile", import.meta.url); const appRoot = Effect.service(Path.Path).pipe( - Effect.flatMap((path) => path.fromFileUrl(new URL("../apps/mobile", import.meta.url))), + Effect.flatMap((path) => path.fromFileUrl(mobileAppRootUrl)), + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "resolve-root", + path: mobileAppRootUrl.pathname, + cause, + }), + ), ); const commandOutputOptions = { @@ -77,7 +117,7 @@ const warnMissingTool = (tool: NativeStaticTool, checkName: string) => `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`, ); -const runCommand = Effect.fn("runCommand")(function* ( +export const runCommand = Effect.fn("runCommand")(function* ( command: string, args: ReadonlyArray, cwd: string, @@ -85,42 +125,86 @@ const runCommand = Effect.fn("runCommand")(function* ( yield* Console.log(`$ ${[command, ...args].join(" ")}`); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const spawnCommand = yield* resolveSpawnCommand(command, args); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd, - ...commandOutputOptions, - shell: spawnCommand.shell, - }), + const processContext = { + command, + argumentCount: spawnCommand.args.length, + cwd, + shell: spawnCommand.shell, + } as const; + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd, + ...commandOutputOptions, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckProcessError({ + ...processContext, + operation: "spawn", + cause, + }), + ), + ); + const exitCode = Number( + yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckProcessError({ + ...processContext, + operation: "wait-for-exit", + cause, + }), + ), + ), ); - const exitCode = Number(yield* child.exitCode); if (exitCode !== 0) { return yield* new NativeStaticCheckCommandError({ - command, - args, - cwd, + ...processContext, exitCode, }); } }); -function collectSources( +export function collectSources( directory: string, root: string, ): Effect.Effect< ReadonlyArray, - PlatformError.PlatformError, + NativeStaticCheckSourceDiscoveryError, FileSystem.FileSystem | Path.Path > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const entries = yield* fs.readDirectory(directory); + const entries = yield* fs.readDirectory(directory).pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "read-directory", + path: directory, + cause, + }), + ), + ); const sources: Array = []; for (const entry of entries) { const entryPath = path.join(directory, entry); - const stat = yield* fs.stat(entryPath); + const stat = yield* fs.stat(entryPath).pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "stat-entry", + path: entryPath, + cause, + }), + ), + ); if (stat.type === "Directory") { const isGeneratedNativeProjectDirectory = From d389cfd4e39ec1b76a6898a04d1b90190155b618 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:19 -0700 Subject: [PATCH 62/80] [codex] Structure Azure DevOps CLI failures (#3460) Co-authored-by: codex --- .../src/sourceControl/AzureDevOpsCli.test.ts | 55 +++- .../src/sourceControl/AzureDevOpsCli.ts | 249 ++++++++++++------ .../AzureDevOpsSourceControlProvider.test.ts | 4 +- 3 files changed, 222 insertions(+), 86 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 8617f14e3655..1cd4b3885521 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -4,8 +4,9 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -345,11 +346,63 @@ describe("AzureDevOpsCli.layer", () => { const az = yield* AzureDevOpsCli.AzureDevOpsCli; const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.operation, "execute"); assert.strictEqual(error.command, "az"); assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.argumentCount, 2); assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); assert.strictEqual(error.cause, cause); assert.equal(error.message.includes("sensitive-upstream-detail"), false); }).pipe(Effect.provide(layer)), ); + + it.effect("does not report a missing working directory as a missing Azure CLI", () => + Effect.gen(function* () { + const cwd = "/missing/repo"; + const platformCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "chdir", + pathOrDescriptor: cwd, + }); + const cause = new VcsProcessSpawnError({ + operation: "AzureDevOpsCli.execute", + command: "az", + cwd, + argumentCount: 2, + cause: platformCause, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.cwd, cwd); + assert.strictEqual(error.cause, cause); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps invalid pull request output diagnostics structured", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.outputLength, 8); + assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); + assert.exists(error.cause); + assert.strictEqual( + error.message, + "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", + ); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index ea0e52868724..609efe4df4c9 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,9 +1,11 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, @@ -19,16 +21,61 @@ import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( - "AzureDevOpsCliError", - { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, +const azureDevOpsCommandErrorFields = { + operation: Schema.Literal("execute"), + command: Schema.Literal("az"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsCliUnavailableError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliUnavailableError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCliAuthenticationError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliAuthenticationError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestNotFoundError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( + "AzureDevOpsCommandFailedError", + azureDevOpsCommandErrorFields, ) { + get detail(): string { + return "Azure DevOps CLI command failed."; + } + override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } @@ -38,53 +85,109 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestListDecodeError", + { + operation: Schema.Literal("listPullRequests"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid PR list JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestDecodeError", + { + operation: Schema.Literal("getPullRequest"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ + "getRepositoryCloneUrls", + "getDefaultBranch", + "createRepository", +]); + +export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsRepositoryDecodeError", + { + operation: AzureDevOpsRepositoryDecodeOperation, + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } +export const AzureDevOpsCliError = Schema.Union([ + AzureDevOpsCliUnavailableError, + AzureDevOpsCliAuthenticationError, + AzureDevOpsPullRequestNotFoundError, + AzureDevOpsCommandFailedError, + AzureDevOpsPullRequestListDecodeError, + AzureDevOpsPullRequestDecodeError, + AzureDevOpsRepositoryDecodeError, +]); +export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; + +export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); + export interface AzureDevOpsRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -146,17 +249,6 @@ export class AzureDevOpsCli extends Context.Service< } >()("t3/sourceControl/AzureDevOpsCli") {} -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - function normalizeChangeRequestId(reference: string): string { const trimmed = reference.trim().replace(/^#/, ""); const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); @@ -225,19 +317,18 @@ function parseRepositorySpecifier(repository: string): { function decodeAzureDevOpsJson( raw: string, schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, + operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, cwd: string, -): Effect.Effect { +): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( - (error) => - new AzureDevOpsCliError({ + (cause) => + new AzureDevOpsRepositoryDecodeError({ operation, command: "az", cwd, - detail: invalidDetail, - cause: error, + outputLength: raw.length, + cause, }), ), ); @@ -257,8 +348,13 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError((error) => - AzureDevOpsCliError.fromVcsError( - { operation: "execute", command: "az", cwd: input.cwd }, + AzureDevOpsCommandFailedError.fromVcsError( + { + operation: "execute", + command: "az", + cwd: input.cwd, + argumentCount: input.args.length, + }, error, ), ), @@ -297,11 +393,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestListDecodeError({ operation: "listPullRequests", command: "az", cwd: input.cwd, - detail: "Azure DevOps CLI returned invalid PR list JSON.", + outputLength: raw.length, cause: decoded.failure, }), ); @@ -331,11 +427,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestDecodeError({ operation: "getPullRequest", command: "az", cwd: input.cwd, - detail: "Azure DevOps CLI returned invalid pull request JSON.", + outputLength: raw.length, cause: decoded.failure, }), ); @@ -357,7 +453,6 @@ export const make = Effect.gen(function* () { raw, RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", - "Azure DevOps CLI returned invalid repository JSON.", input.cwd, ), ), @@ -383,13 +478,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "createRepository", - "Azure DevOps CLI returned invalid repository JSON.", - input.cwd, - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), ), Effect.map(normalizeRepositoryCloneUrls), ); @@ -421,13 +510,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "getDefaultBranch", - "Azure DevOps CLI returned invalid repository JSON.", - input.cwd, - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 1341f4cc08d6..21db25e79912 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -48,11 +48,11 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" it.effect("adds change-request context while retaining Azure CLI causes", () => Effect.gen(function* () { - const cause = new AzureDevOpsCli.AzureDevOpsCliError({ + const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ operation: "execute", command: "az", cwd: "/repo", - detail: "Azure DevOps CLI command failed.", + argumentCount: 2, cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({ From 60dc4af916d0a62840d74fe95fe104e8b1c4ac92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:58:22 -0700 Subject: [PATCH 63/80] [codex] Structure desktop build script failures (#3452) Co-authored-by: codex --- scripts/build-desktop-artifact.test.ts | 45 ++- scripts/build-desktop-artifact.ts | 379 ++++++++++++++++++++----- 2 files changed, 349 insertions(+), 75 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 62823f7fc819..99aea602e8c9 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -9,15 +9,17 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - BuildScriptError, + BuildCommandFailedError, createStageWorkspaceConfig, createStagePnpmConfig, createBuildConfig, DESKTOP_ASAR_UNPACK, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, + InvalidMockUpdateServerPortError, isMacPasskeySigningConfigurationError, LinuxIconResizeError, + MacPasskeySigningConfigurationResolutionError, MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, @@ -249,10 +251,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { const aggregateCause = error.cause as AggregateError; assert.lengthOf(aggregateCause.errors, 2); assert.strictEqual(aggregateCause.cause, aggregateCause.errors[0]); - assert.instanceOf(aggregateCause.errors[0], BuildScriptError); - assert.instanceOf(aggregateCause.errors[1], BuildScriptError); - assert.include((aggregateCause.errors[0] as BuildScriptError).message, "magick linux icon"); - assert.include((aggregateCause.errors[1] as BuildScriptError).message, "convert linux icon"); + assert.instanceOf(aggregateCause.errors[0], BuildCommandFailedError); + assert.instanceOf(aggregateCause.errors[1], BuildCommandFailedError); + const primaryError = aggregateCause.errors[0] as BuildCommandFailedError; + const fallbackError = aggregateCause.errors[1] as BuildCommandFailedError; + assert.equal(primaryError.command, "magick linux icon 512x512"); + assert.equal(primaryError.exitCode, 1); + assert.include(primaryError.message, "magick linux icon"); + assert.equal(fallbackError.command, "convert linux icon 512x512"); + assert.equal(fallbackError.exitCode, 2); + assert.include(fallbackError.message, "convert linux icon"); assert.deepStrictEqual( commands.map(({ command }) => command), ["magick", "convert"], @@ -356,7 +364,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("preserves known passkey signing configuration errors at the build boundary", () => { const decodingCause = new Error("publishable-key-decode-failed"); const knownError = new InvalidMacPasskeyPublishableKeyError({ cause: decodingCause }); - const error = BuildScriptError.fromMacPasskeySigningConfiguration(knownError); + const error = MacPasskeySigningConfigurationResolutionError.fromCause(knownError); assert.strictEqual(error, knownError); assert.instanceOf(error, InvalidMacPasskeyPublishableKeyError); @@ -367,9 +375,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("wraps unknown passkey signing configuration defects without copying cause text", () => { const secret = "pk_test_do-not-retain"; const cause = new Error(secret); - const error = BuildScriptError.fromMacPasskeySigningConfiguration(cause); + const error = MacPasskeySigningConfigurationResolutionError.fromCause(cause); - assert.instanceOf(error, BuildScriptError); + assert.instanceOf(error, MacPasskeySigningConfigurationResolutionError); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Failed to resolve macOS passkey signing configuration."); assert.notInclude(error.message, secret); @@ -453,6 +461,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it("classifies invalid configured ports with the decoder's number grammar", () => { + const cause = new Error("invalid configured port"); + + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("0x10", cause).reason, + "not-numeric", + ); + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("12.5", cause).reason, + "not-integer", + ); + assert.equal( + InvalidMockUpdateServerPortError.fromConfigValue("65536", cause).reason, + "out-of-range", + ); + assert.strictEqual( + InvalidMockUpdateServerPortError.fromConfigValue("0x10", cause).cause, + cause, + ); + }); + it.effect("resolves default platform and architecture from host references", () => Effect.gen(function* () { const resolved = yield* resolveBuildOptions({ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f1d03f615097..5a6cbfb8be3e 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -18,7 +18,6 @@ import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -126,19 +125,238 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); -export class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ - readonly message: string; - readonly cause?: unknown; -}> { - static fromMacPasskeySigningConfiguration( +export class MacPasskeySigningConfigurationResolutionError extends Schema.TaggedErrorClass()( + "MacPasskeySigningConfigurationResolutionError", + { + cause: Schema.Defect(), + }, +) { + static fromCause( cause: unknown, - ): MacPasskeySigningConfigurationError | BuildScriptError { + ): MacPasskeySigningConfigurationError | MacPasskeySigningConfigurationResolutionError { return isMacPasskeySigningConfigurationError(cause) ? cause - : new BuildScriptError({ - message: "Failed to resolve macOS passkey signing configuration.", - cause, - }); + : new MacPasskeySigningConfigurationResolutionError({ cause }); + } + + override get message(): string { + return "Failed to resolve macOS passkey signing configuration."; + } +} + +export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass()( + "ClerkPasskeyNativePackageMissingError", + { + packageName: Schema.String, + binaryFileName: Schema.String, + packageEntryPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Clerk passkey native package is missing: ${this.packageName}`; + } +} + +export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass()( + "UnsupportedHostBuildPlatformError", + { + hostPlatform: Schema.String, + }, +) { + override get message(): string { + return `Unsupported host platform '${this.hostPlatform}'.`; + } +} + +const InvalidMockUpdateServerPortReason = Schema.Literals([ + "not-numeric", + "not-integer", + "out-of-range", +]); + +export class InvalidMockUpdateServerPortError extends Schema.TaggedErrorClass()( + "InvalidMockUpdateServerPortError", + { + reason: InvalidMockUpdateServerPortReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid mock update server port."; + } + + static fromConfigValue(configuredPort: string, cause: unknown) { + return new InvalidMockUpdateServerPortError({ + reason: invalidMockUpdateServerPortReason(configuredPort), + inputLength: configuredPort.length, + cause, + }); + } +} + +export class BuildCommandFailedError extends Schema.TaggedErrorClass()( + "BuildCommandFailedError", + { + command: Schema.String, + exitCode: Schema.Int, + stdoutTail: Schema.optionalKey(Schema.String), + stderrTail: Schema.optionalKey(Schema.String), + }, +) { + override get message(): string { + const outputSections = [ + `Command: ${this.command}`, + formatOutputSection("stdout", this.stdoutTail ?? ""), + formatOutputSection("stderr", this.stderrTail ?? ""), + ].filter((section): section is string => section !== undefined); + const outputSuffix = outputSections.length > 0 ? `\n\n${outputSections.join("\n\n")}` : ""; + return `Command exited with non-zero exit code (${this.exitCode})${outputSuffix}`; + } +} + +const desktopIconPlatformNames = { + mac: "macOS", + linux: "Linux", + win: "Windows", +} satisfies Record; + +export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass()( + "DesktopIconSourceMissingError", + { + platform: BuildPlatform, + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Desktop ${desktopIconPlatformNames[this.platform]} icon source is missing at ${this.sourcePath}`; + } +} + +export class BundledClientAssetsMissingError extends Schema.TaggedErrorClass()( + "BundledClientAssetsMissingError", + { + indexPath: Schema.String, + missingFiles: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const preview = this.missingFiles.slice(0, 6).join(", "); + const suffix = this.missingFiles.length > 6 ? ` (+${this.missingFiles.length - 6} more)` : ""; + return `Bundled client references missing files in ${this.indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`; + } +} + +export class UnsupportedDesktopBuildPlatformError extends Schema.TaggedErrorClass()( + "UnsupportedDesktopBuildPlatformError", + { + platform: Schema.String, + }, +) { + override get message(): string { + return `Unsupported platform '${this.platform}'.`; + } +} + +const dependencyResolutionDescriptions = { + "server-production": "production dependencies", + "workspace-overrides": "overrides", + "desktop-runtime": "desktop runtime dependencies", +} as const; +const DependencyResolutionKind = Schema.Literals([ + "server-production", + "workspace-overrides", + "desktop-runtime", +]); + +export class DesktopBuildDependencyResolutionError extends Schema.TaggedErrorClass()( + "DesktopBuildDependencyResolutionError", + { + kind: DependencyResolutionKind, + manifestPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not resolve ${dependencyResolutionDescriptions[this.kind]} from ${this.manifestPath}.`; + } +} + +export class MissingServerProductionDependenciesError extends Schema.TaggedErrorClass()( + "MissingServerProductionDependenciesError", + { + manifestPath: Schema.String, + }, +) { + override get message(): string { + return `Could not resolve production dependencies from ${this.manifestPath}.`; + } +} + +const DesktopBuildInputArtifact = Schema.Literals([ + "desktop-dist", + "desktop-resources", + "server-dist", + "bundled-server-client", +]); +type DesktopBuildInputArtifact = typeof DesktopBuildInputArtifact.Type; +const desktopBuildInputArtifactNames = { + "desktop-dist": "desktopDist", + "desktop-resources": "desktopResources", + "server-dist": "serverDist", + "bundled-server-client": "bundled server client", +} satisfies Record; + +export class MissingDesktopBuildInputError extends Schema.TaggedErrorClass()( + "MissingDesktopBuildInputError", + { + artifact: DesktopBuildInputArtifact, + artifactPath: Schema.String, + buildCommand: Schema.Literal("vp run build:desktop"), + }, +) { + override get message(): string { + return `Missing ${desktopBuildInputArtifactNames[this.artifact]} at ${this.artifactPath}. Run '${this.buildCommand}' first.`; + } +} + +export class MacProvisioningProfileNotFoundError extends Schema.TaggedErrorClass()( + "MacProvisioningProfileNotFoundError", + { + provisioningProfilePath: Schema.String, + }, +) { + override get message(): string { + return `macOS provisioning profile not found: ${this.provisioningProfilePath}`; + } +} + +export class DesktopBuildDistDirectoryMissingError extends Schema.TaggedErrorClass()( + "DesktopBuildDistDirectoryMissingError", + { + distPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + }, +) { + override get message(): string { + return `Build completed but dist directory was not found at ${this.distPath}`; + } +} + +export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedErrorClass()( + "DesktopBuildNoArtifactsProducedError", + { + distPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + }, +) { + override get message(): string { + return `Build completed but no files were produced in ${this.distPath}`; } } @@ -616,8 +834,12 @@ const stageClerkPasskeyNativeBinaries = Effect.fn("stageClerkPasskeyNativeBinari const sourcePath = yield* Effect.try({ try: () => packageRequire.resolve(artifact.packageName), catch: (cause) => - new BuildScriptError({ - message: `Clerk passkey native package is missing: ${artifact.packageName}`, + new ClerkPasskeyNativePackageMissingError({ + packageName: artifact.packageName, + binaryFileName: artifact.binaryFileName, + packageEntryPath, + platform, + arch, cause, }), }); @@ -691,6 +913,18 @@ const MockUpdateServerPortSchema = Schema.NumberFromString.check( ); const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema); +function invalidMockUpdateServerPortReason( + configuredPort: string, +): typeof InvalidMockUpdateServerPortReason.Type { + const parsed = Number(configuredPort); + if (!Number.isFinite(parsed)) return "not-numeric"; + if (!Number.isInteger(parsed)) return "not-integer"; + if (parsed < 1 || parsed > 65535) return "out-of-range"; + // This mapper is only called after schema decoding failed. An otherwise + // valid integer therefore used a representation the decoder did not accept. + return "not-numeric"; +} + const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(flag, () => envValue); const mergeOptions = (a: Option.Option, b: Option.Option, defaultValue: A) => @@ -722,9 +956,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( ); if (!platform) { - return yield* new BuildScriptError({ - message: `Unsupported host platform '${hostPlatform}'.`, - }); + return yield* new UnsupportedHostBuildPlatformError({ hostPlatform }); } const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); @@ -745,17 +977,16 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const verbose = resolveBooleanFlag(input.verbose, env.verbose); const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); + const configuredMockUpdateServerPort = Option.getOrUndefined(env.mockUpdateServerPort); const mockUpdateServerPort = Option.getOrUndefined(input.mockUpdateServerPort) ?? - (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe( - Effect.mapError( - (cause) => - new BuildScriptError({ - message: "Invalid mock update server port.", - cause, - }), - ), - )); + (configuredMockUpdateServerPort === undefined + ? undefined + : yield* resolveMockUpdateServerPort(configuredMockUpdateServerPort).pipe( + Effect.mapError((cause) => + InvalidMockUpdateServerPortError.fromConfigValue(configuredMockUpdateServerPort, cause), + ), + )); return { platform, @@ -775,7 +1006,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const runCommand = Effect.fn("runCommand")(function* ( command: ChildProcess.Command, options: { - readonly label?: string; + readonly label: string; readonly verbose: boolean; }, ) { @@ -791,14 +1022,11 @@ const runCommand = Effect.fn("runCommand")(function* ( ); if (exitCode !== 0) { - const outputSections = [ - options.label ? `Command: ${options.label}` : undefined, - formatOutputSection("stdout", stdout), - formatOutputSection("stderr", stderr), - ].filter((section): section is string => section !== undefined); - const outputSuffix = outputSections.length > 0 ? `\n\n${outputSections.join("\n\n")}` : ""; - return yield* new BuildScriptError({ - message: `Command exited with non-zero exit code (${exitCode})${outputSuffix}`, + return yield* new BuildCommandFailedError({ + command: options.label, + exitCode, + ...(stdout.trim() ? { stdoutTail: stdout } : {}), + ...(stderr.trim() ? { stderrTail: stderr } : {}), }); } }); @@ -845,8 +1073,9 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourcePng))) { - return yield* new BuildScriptError({ - message: `Desktop macOS icon source is missing at ${sourcePng}`, + return yield* new DesktopIconSourceMissingError({ + platform: "mac", + sourcePath: sourcePng, }); } @@ -871,8 +1100,9 @@ function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourcePng))) { - return yield* new BuildScriptError({ - message: `Desktop Linux icon source is missing at ${sourcePng}`, + return yield* new DesktopIconSourceMissingError({ + platform: "linux", + sourcePath: sourcePng, }); } @@ -931,8 +1161,9 @@ function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!(yield* fs.exists(sourceIco))) { - return yield* new BuildScriptError({ - message: `Desktop Windows icon source is missing at ${sourceIco}`, + return yield* new DesktopIconSourceMissingError({ + platform: "win", + sourcePath: sourceIco, }); } @@ -969,10 +1200,9 @@ function validateBundledClientAssets(clientDir: string) { } if (missing.length > 0) { - const preview = missing.slice(0, 6).join(", "); - const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; - return yield* new BuildScriptError({ - message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, + return yield* new BundledClientAssetsMissingError({ + indexPath, + missingFiles: missing, }); } }); @@ -1174,8 +1404,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const platformConfig = PLATFORM_CONFIG[options.platform]; if (!platformConfig) { - return yield* new BuildScriptError({ - message: `Unsupported platform '${options.platform}'.`, + return yield* new UnsupportedDesktopBuildPlatformError({ + platform: options.platform, }); } @@ -1183,16 +1413,17 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const serverDependencies = serverPackageJson.dependencies; if (!serverDependencies || Object.keys(serverDependencies).length === 0) { - return yield* new BuildScriptError({ - message: "Could not resolve production dependencies from apps/server/package.json.", + return yield* new MissingServerProductionDependenciesError({ + manifestPath: "apps/server/package.json", }); } const resolvedOverrides = yield* Effect.try({ try: () => resolveCatalogDependencies(workspaceOverrides, workspaceCatalog, "apps/desktop"), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve overrides from pnpm-workspace.yaml.", + new DesktopBuildDependencyResolutionError({ + kind: "workspace-overrides", + manifestPath: "pnpm-workspace.yaml", cause, }), }); @@ -1200,16 +1431,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const resolvedServerDependencies = yield* Effect.try({ try: () => resolveCatalogDependencies(serverDependencies, workspaceCatalog, "apps/server"), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve production dependencies from apps/server/package.json.", + new DesktopBuildDependencyResolutionError({ + kind: "server-production", + manifestPath: "apps/server/package.json", cause, }), }); const resolvedDesktopRuntimeDependencies = yield* Effect.try({ try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog), catch: (cause) => - new BuildScriptError({ - message: "Could not resolve desktop runtime dependencies from apps/desktop/package.json.", + new DesktopBuildDependencyResolutionError({ + kind: "desktop-runtime", + manifestPath: "apps/desktop/package.json", cause, }), }); @@ -1243,17 +1476,25 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); } - for (const [label, dir] of Object.entries(distDirs)) { - if (!(yield* fs.exists(dir))) { - return yield* new BuildScriptError({ - message: `Missing ${label} at ${dir}. Run 'vp run build:desktop' first.`, + const requiredBuildInputs = [ + { artifact: "desktop-dist", artifactPath: distDirs.desktopDist }, + { artifact: "desktop-resources", artifactPath: distDirs.desktopResources }, + { artifact: "server-dist", artifactPath: distDirs.serverDist }, + ] as const; + for (const input of requiredBuildInputs) { + if (!(yield* fs.exists(input.artifactPath))) { + return yield* new MissingDesktopBuildInputError({ + ...input, + buildCommand: "vp run build:desktop", }); } } if (!(yield* fs.exists(bundledClientEntry))) { - return yield* new BuildScriptError({ - message: `Missing bundled server client at ${bundledClientEntry}. Run 'vp run build:desktop' first.`, + return yield* new MissingDesktopBuildInputError({ + artifact: "bundled-server-client", + artifactPath: bundledClientEntry, + buildCommand: "vp run build:desktop", }); } @@ -1285,7 +1526,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.platform === "mac" && options.signed ? yield* Effect.try({ try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), - catch: BuildScriptError.fromMacPasskeySigningConfiguration, + catch: MacPasskeySigningConfigurationResolutionError.fromCause, }) : undefined; const macPasskeySigning = configuredMacPasskeySigning @@ -1302,8 +1543,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( : undefined; if (macPasskeySigning && macEntitlementsPath) { if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { - return yield* new BuildScriptError({ - message: `macOS provisioning profile not found: ${macPasskeySigning.provisioningProfilePath}`, + return yield* new MacProvisioningProfileNotFoundError({ + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, }); } yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); @@ -1442,8 +1683,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stageDistDir = path.join(stageAppDir, "dist"); if (!(yield* fs.exists(stageDistDir))) { - return yield* new BuildScriptError({ - message: `Build completed but dist directory was not found at ${stageDistDir}`, + return yield* new DesktopBuildDistDirectoryMissingError({ + distPath: stageDistDir, + platform: options.platform, + arch: options.arch, }); } @@ -1462,8 +1705,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (copiedArtifacts.length === 0) { - return yield* new BuildScriptError({ - message: `Build completed but no files were produced in ${stageDistDir}`, + return yield* new DesktopBuildNoArtifactsProducedError({ + distPath: stageDistDir, + platform: options.platform, + arch: options.arch, }); } From 8c2d33abde95fa21595f4e3b49ff3c5ba2e3cab4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:09:02 -0700 Subject: [PATCH 64/80] [codex] Structure release package updater failures (#3468) Co-authored-by: codex --- .../update-release-package-versions.test.ts | 108 +++++++++++++++++- scripts/update-release-package-versions.ts | 96 +++++++++++++++- 2 files changed, 198 insertions(+), 6 deletions(-) diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 802e13f35d90..01a27f3273b5 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -1,16 +1,21 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { Command, CliError } from "effect/unstable/cli"; import * as TestConsole from "effect/testing/TestConsole"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { + ReleaseGitHubOutputConfigurationError, + ReleaseGitHubOutputWriteError, + ReleasePackageManifestError, releasePackageFiles, updateReleasePackageVersions, updateReleasePackageVersionsCommand, @@ -103,6 +108,73 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { }), ); + it.effect("preserves manifest read context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-read-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip); + + assert.instanceOf(error, ReleasePackageManifestError); + assert.equal(error.operation, "read"); + assert.equal(error.filePath, filePath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, `Failed to read release package manifest '${filePath}'.`); + }), + ); + + it.effect("preserves manifest decode context and the schema cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-decode-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + yield* fs.writeFileString(filePath, "not json"); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip); + + assert.equal(error.operation, "decode"); + assert.equal(error.filePath, filePath); + assert.isTrue(Schema.isSchemaError(error.cause)); + assert.equal(error.message, `Failed to decode release package manifest '${filePath}'.`); + }), + ); + + it.effect("preserves manifest write context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-write-error-", + }); + const filePath = path.join(baseDir, releasePackageFiles[0]); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + yield* fs.chmod(filePath, 0o400); + + const error = yield* updateReleasePackageVersions("1.2.3", { + rootDir: baseDir, + }).pipe(Effect.flip, Effect.ensuring(fs.chmod(filePath, 0o600).pipe(Effect.orDie))); + + assert.equal(error.operation, "write"); + assert.equal(error.filePath, filePath); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal(error.message, `Failed to write release package manifest '${filePath}'.`); + }), + ); + it.effect("accepts flags before the version positional and appends changed output", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -164,9 +236,43 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { Effect.flip, ); + assert.instanceOf(error, ReleaseGitHubOutputConfigurationError); + assert.instanceOf(error.cause, Config.ConfigError); + assert.equal( + error.message, + "Failed to resolve GITHUB_OUTPUT for release package version output.", + ); + }), + ); + + it.effect("preserves GITHUB_OUTPUT write context and the filesystem cause", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-output-error-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_OUTPUT: baseDir, + }, + }), + ), + ), + Effect.flip, + ); + + assert.instanceOf(error, ReleaseGitHubOutputWriteError); + assert.equal(error.filePath, baseDir); + assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal( error.message, - 'SchemaError(Expected string, got undefined\n at ["GITHUB_OUTPUT"])', + `Failed to append release package version output to '${baseDir}'.`, ); }), ); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index cebf434d0ae0..5465b5085120 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -12,6 +12,40 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +export class ReleasePackageManifestError extends Schema.TaggedErrorClass()( + "ReleasePackageManifestError", + { + operation: Schema.Literals(["read", "decode", "encode", "write"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} release package manifest '${this.filePath}'.`; + } +} + +export class ReleaseGitHubOutputConfigurationError extends Schema.TaggedErrorClass()( + "ReleaseGitHubOutputConfigurationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to resolve GITHUB_OUTPUT for release package version output."; + } +} + +export class ReleaseGitHubOutputWriteError extends Schema.TaggedErrorClass()( + "ReleaseGitHubOutputWriteError", + { + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append release package version output to '${this.filePath}'.`; + } +} + export const releasePackageFiles = [ "apps/server/package.json", "apps/desktop/package.json", @@ -39,13 +73,50 @@ export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersi for (const relativePath of releasePackageFiles) { const filePath = path.join(rootDir, relativePath); - const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); + const packageJsonText = yield* fs.readFileString(filePath).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "read", + filePath, + cause, + }), + ), + ); + const packageJson = yield* decodePackageJson(packageJsonText).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "decode", + filePath, + cause, + }), + ), + ); if (packageJson.version === version) { continue; } - const packageJsonString = yield* encodePackageJson({ ...packageJson, version }); - yield* fs.writeFileString(filePath, `${packageJsonString}\n`); + const packageJsonString = yield* encodePackageJson({ ...packageJson, version }).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "encode", + filePath, + cause, + }), + ), + ); + yield* fs.writeFileString(filePath, `${packageJsonString}\n`).pipe( + Effect.mapError( + (cause) => + new ReleasePackageManifestError({ + operation: "write", + filePath, + cause, + }), + ), + ); changed = true; } @@ -54,8 +125,23 @@ export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersi const writeGithubOutput = Effect.fn("writeGithubOutput")(function* (changed: boolean) { const fs = yield* FileSystem.FileSystem; - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); - yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new ReleaseGitHubOutputConfigurationError({ + cause, + }), + ), + ); + yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new ReleaseGitHubOutputWriteError({ + filePath: githubOutputPath, + cause, + }), + ), + ); }); export const updateReleasePackageVersionsCommand = Command.make( From 1b2f39d018410a9e09c67e2629f6ca1562860e6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:09:05 -0700 Subject: [PATCH 65/80] [codex] Structure theme synchronization failures (#3466) Co-authored-by: codex --- apps/web/src/hooks/useTheme.test.ts | 193 ++++++++++++++++++++++++++++ apps/web/src/hooks/useTheme.ts | 146 +++++++++++++++++++-- 2 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/hooks/useTheme.test.ts diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 000000000000..6c814e301650 --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +afterEach(() => { + vi.doUnmock("react"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("theme failure handling", () => { + it("preserves exact storage causes and operation context", async () => { + const readCause = new Error("storage read blocked"); + const writeCause = new Error("storage quota exceeded"); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw readCause; + }, + setItem: () => { + throw writeCause; + }, + }), + }); + + const { readThemePreference, ThemeStorageError, writeThemePreference } = + await import("./useTheme"); + + try { + readThemePreference(); + expect.unreachable("expected the theme read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "t3code:theme", + cause: readCause, + }); + } + + try { + writeThemePreference("dark"); + expect.unreachable("expected the theme write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "t3code:theme", + theme: "dark", + cause: writeCause, + }); + } + }); + + it("falls back during initial theme application and logs only safe attributes", async () => { + const cause = new Error("private browsing storage failure"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw cause; + }, + }), + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + }, + }); + + await expect(import("./useTheme")).resolves.toBeDefined(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to read theme preference for t3code:theme.", + expect.objectContaining({ + operation: "read", + storageKey: "t3code:theme", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + }); + + it("retries a failed storage read only after a relevant storage event", async () => { + const cause = new Error("persistent storage failure"); + const getItem = vi.fn(() => { + throw cause; + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + let readSnapshot: (() => unknown) | undefined; + let subscribeToTheme: ((listener: () => void) => () => void) | undefined; + let storageHandler: ((event: StorageEvent) => void) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribeToTheme = subscribe; + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: createStorage({ getItem }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + + const { useTheme } = await import("./useTheme"); + useTheme(); + readSnapshot?.(); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(1); + expect(errorLog).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToTheme?.(() => undefined); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { + const cause = new Error("desktop IPC unavailable"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const setTheme = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", { desktopBridge: { setTheme } }); + + const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } = + await import("./useTheme"); + + const error = await syncDesktopThemePreference({ setTheme }, "dark").then( + () => undefined, + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(DesktopThemeSyncError); + expect(error).toMatchObject({ theme: "dark", cause }); + + setTheme.mockClear(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + + expect(setTheme).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledWith( + "Failed to sync the dark theme to the desktop shell.", + expect.objectContaining({ + theme: "dark", + errorTag: "DesktopThemeSyncError", + }), + ); + for (const [, attributes] of errorLog.mock.calls) { + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + } + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index eec2e9c9363e..bdaf37f099d2 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,11 +1,17 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; -type Theme = "light" | "dark" | "system"; +const ThemePreference = Schema.Literals(["light", "dark", "system"]); +type Theme = typeof ThemePreference.Type; type ThemeSnapshot = { theme: Theme; systemDark: boolean; }; +type DesktopThemeBridge = Pick; + const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { @@ -15,19 +21,46 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; +export class ThemeStorageError extends Schema.TaggedErrorClass()( + "ThemeStorageError", + { + operation: Schema.Literals(["read", "write"]), + storageKey: Schema.String, + theme: Schema.optional(ThemePreference), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; + } +} + +export const isThemeStorageError = Schema.is(ThemeStorageError); + +export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( + "DesktopThemeSyncError", + { + theme: ThemePreference, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sync the ${this.theme} theme to the desktop shell.`; + } +} + +export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); + let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; let lastAppliedTheme: ThemeSnapshot | null = null; +let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { for (const listener of listeners) listener(); } -function hasThemeStorage() { - return typeof window !== "undefined" && typeof localStorage !== "undefined"; -} - function getSystemDark() { return ( typeof window !== "undefined" && @@ -36,13 +69,61 @@ function getSystemDark() { ); } -function getStored(): Theme { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; - const raw = localStorage.getItem(STORAGE_KEY); +export function readThemePreference(): Theme { + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; + let raw: string | null; + try { + raw = window.localStorage.getItem(STORAGE_KEY); + } catch (cause) { + throw new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + } if (raw === "light" || raw === "dark" || raw === "system") return raw; return DEFAULT_THEME_SNAPSHOT.theme; } +export function writeThemePreference(theme: Theme): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, theme); + themeStorageReadFailure = null; + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme, + cause, + }); + } +} + +function getStored(): Theme { + if (themeStorageReadFailure !== null) { + return DEFAULT_THEME_SNAPSHOT.theme; + } + try { + return readThemePreference(); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + themeStorageReadFailure = error; + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + ...safeErrorLogAttributes(error), + }); + return DEFAULT_THEME_SNAPSHOT.theme; + } +} + function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) { @@ -118,7 +199,18 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } } -function syncDesktopTheme(theme: Theme) { +export async function syncDesktopThemePreference( + bridge: DesktopThemeBridge, + theme: Theme, +): Promise { + try { + await bridge.setTheme(theme); + } catch (cause) { + throw new DesktopThemeSyncError({ theme, cause }); + } +} + +export function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { @@ -126,7 +218,14 @@ function syncDesktopTheme(theme: Theme) { } lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { + void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { + const error = isDesktopThemeSyncError(cause) + ? cause + : new DesktopThemeSyncError({ theme, cause }); + console.error(error.message, { + theme: error.theme, + ...safeErrorLogAttributes(error), + }); if (lastDesktopTheme === theme) { lastDesktopTheme = null; } @@ -134,12 +233,12 @@ function syncDesktopTheme(theme: Theme) { } // Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && hasThemeStorage()) { +if (typeof document !== "undefined" && typeof window !== "undefined") { applyTheme(getStored()); } function getSnapshot(): ThemeSnapshot { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); const systemDark = theme === "system" ? getSystemDark() : false; @@ -170,6 +269,7 @@ function subscribe(listener: () => void): () => void { // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY) { + themeStorageReadFailure = null; applyTheme(getStored(), true); emitChange(); } @@ -191,8 +291,26 @@ export function useTheme() { theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { - if (!hasThemeStorage()) return; - localStorage.setItem(STORAGE_KEY, next); + if (typeof window === "undefined") return; + try { + writeThemePreference(next); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme: next, + cause, + }); + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + theme: next, + ...safeErrorLogAttributes(error), + }); + return; + } applyTheme(next, true); emitChange(); }, []); From 61e6d89d69240120dd08fae72459e5fd7e7a2908 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:14:35 -0700 Subject: [PATCH 66/80] [codex] Structure GitHub CLI failures (#3456) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 56 ++-- .../src/sourceControl/GitHubCli.test.ts | 26 +- apps/server/src/sourceControl/GitHubCli.ts | 270 +++++++++++------- .../GitHubSourceControlProvider.test.ts | 4 +- .../GitHubSourceControlProvider.ts | 14 +- 5 files changed, 212 insertions(+), 158 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c06915c51b94..e1924c03adea 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -171,20 +171,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { if (result.status === 0) { return; } - throw new GitHubCli.GitHubCliError({ - operation: "execute", - command: "gh", - cwd, - detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, - }); -} - -function isGitHubCliError(error: unknown): error is GitHubCli.GitHubCliError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - (error as { _tag?: unknown })._tag === "GitHubCliError" + throw new Error( + `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, ); } @@ -478,16 +466,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - isGitHubCliError(error) + GitHubCli.isGitHubCliError(error) ? error - : new GitHubCli.GitHubCliError({ - operation: "execute", + : new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: - error instanceof Error - ? `Failed to simulate gh checkout: ${error.message}` - : "Failed to simulate gh checkout.", + cause: error, }), }); } @@ -498,11 +482,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "execute", + new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: `Unexpected repository lookup: ${repository}`, + cause: new Error(`Unexpected repository lookup: ${repository}`), }), ); } @@ -520,11 +503,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "execute", + new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: `Unexpected gh command: ${args.join(" ")}`, + cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), ); }; @@ -601,11 +583,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "createRepository", + new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: input.cwd, - detail: `Unexpected repository create: ${input.repository}`, + cause: new Error(`Unexpected repository create: ${input.repository}`), }), ), checkoutPullRequest: (input) => @@ -1341,11 +1322,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliError({ - operation: "execute", + failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: new Error("gh is not available on PATH"), }), }, }); @@ -2483,11 +2463,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliError({ - operation: "execute", + failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", cwd: repoDir, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: new Error("gh is not available on PATH"), }), }, }); @@ -2514,11 +2493,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliError({ - operation: "execute", + failWith: new GitHubCli.GitHubCliAuthenticationError({ command: "gh", cwd: repoDir, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: new Error("gh is not authenticated"), }), }, }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 7c8c9b037be6..5df4862b4091 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,8 +1,9 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -30,6 +31,27 @@ afterEach(() => { }); describe("GitHubCli.layer", () => { + it("does not classify a missing cwd as an unavailable gh executable", () => { + const context = { command: "gh", cwd: "/repo" } as const; + const missingCwd = new VcsProcessSpawnError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: context.cwd, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "access", + pathOrDescriptor: context.cwd, + }), + }); + + const commandFailure = GitHubCli.fromVcsError(context, missingCwd); + + assert.equal(commandFailure._tag, "GitHubCliCommandError"); + assert.strictEqual(commandFailure.cause, missingCwd); + assert.notProperty(commandFailure, "operation"); + }); + it.effect("parses pull request view output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -274,6 +296,7 @@ describe("GitHubCli.layer", () => { command: "gh pr view", cwd: "/repo", exitCode: 1, + failureKind: "not-found", detail: "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", }); @@ -288,6 +311,7 @@ describe("GitHubCli.layer", () => { .pipe(Effect.flip); assert.equal(error.message.includes("Pull request not found"), true); + assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); assert.strictEqual(error.command, "gh"); assert.strictEqual(error.cwd, "/repo"); assert.strictEqual(error.cause, cause); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 4cdf38ec2b81..bf3f27378b5e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -18,69 +19,165 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; -export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { - operation: Schema.String, - command: Schema.String, +const gitHubCliFailureFields = { + command: Schema.Literal("gh"), cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { + cause: Schema.Defect(), +} as const; + +export class GitHubCliUnavailableError extends Schema.TaggedErrorClass()( + "GitHubCliUnavailableError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI (`gh`) is required but not available on PATH."; + } + override get message(): string { - return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + return `GitHub CLI failed in execute: ${this.detail}`; } +} - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "gh"; - readonly cwd: string; - }, - error: VcsError | unknown, - ): GitHubCliError { - const lower = errorText(error).toLowerCase(); - - if (lower.includes("command not found: gh") || lower.includes("enoent")) { - return new GitHubCliError({ - ...context, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); - } +export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitHubCliAuthenticationError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; + } - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - ...context, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} - if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") - ) { - return new GitHubCliError({ - ...context, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } +export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubPullRequestNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + +export class GitHubCliCommandError extends Schema.TaggedErrorClass()( + "GitHubCliCommandError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub CLI command failed."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + +const gitHubCliDecodeFields = { + command: Schema.Literal("gh"), + cwd: Schema.String, + cause: Schema.Defect(), +} as const; + +export class GitHubPullRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid PR list JSON."; + } + + override get message(): string { + return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; + } +} + +export class GitHubChangeRequestListDecodeError extends Schema.TaggedErrorClass()( + "GitHubChangeRequestListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid change request JSON."; + } - return new GitHubCliError({ - ...context, - detail: "GitHub CLI command failed.", - cause: error, - }); + override get message(): string { + return `GitHub CLI failed in listChangeRequests: ${this.detail}`; } } +export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequest: ${this.detail}`; + } +} + +export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubRepositoryDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository JSON."; + } + + override get message(): string { + return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; + } +} + +export const GitHubCliError = Schema.Union([ + GitHubCliUnavailableError, + GitHubCliAuthenticationError, + GitHubPullRequestNotFoundError, + GitHubCliCommandError, + GitHubPullRequestListDecodeError, + GitHubChangeRequestListDecodeError, + GitHubPullRequestDecodeError, + GitHubRepositoryDecodeError, +]); +export type GitHubCliError = typeof GitHubCliError.Type; + +export const isGitHubCliError = Schema.is(GitHubCliError); + +export function fromVcsError( + context: { + readonly command: "gh"; + readonly cwd: string; + }, + error: VcsError, +): GitHubCliError { + if ( + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" && + error.cause.reason.module === "ChildProcess" && + error.cause.reason.method === "spawn" + ) { + return new GitHubCliUnavailableError({ ...context, cause: error }); + } + + if (error._tag === "VcsProcessExitError") { + if (error.failureKind === "authentication") { + return new GitHubCliAuthenticationError({ ...context, cause: error }); + } + if (error.failureKind === "not-found") { + return new GitHubPullRequestNotFoundError({ ...context, cause: error }); + } + } + + return new GitHubCliCommandError({ ...context, cause: error }); +} + export interface GitHubPullRequestSummary { readonly number: number; readonly title: string; @@ -150,22 +247,14 @@ export class GitHubCli extends Context.Service< } >()("t3/sourceControl/GitHubCli") {} -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); +const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), +); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, @@ -214,27 +303,6 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } -function decodeGitHubJson( - raw: string, - schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", - invalidDetail: string, - cwd: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitHubCliError({ - operation, - command: "gh", - cwd, - detail: invalidDetail, - cause: error, - }), - ), - ); -} - export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; @@ -247,14 +315,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) - .pipe( - Effect.mapError((error) => - GitHubCliError.fromVcsError( - { operation: "execute", command: "gh", cwd: input.cwd }, - error, - ), - ), - ); + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); return GitHubCli.of({ execute, @@ -282,11 +343,9 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "listOpenPullRequests", + new GitHubPullRequestListDecodeError({ command: "gh", cwd: input.cwd, - detail: "GitHub CLI returned invalid PR list JSON.", cause: decoded.failure, }), ); @@ -316,11 +375,9 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new GitHubCliError({ - operation: "getPullRequest", + new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, - detail: "GitHub CLI returned invalid pull request JSON.", cause: decoded.failure, }), ); @@ -340,12 +397,15 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitHub CLI returned invalid repository JSON.", - input.cwd, + decodeRawGitHubRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositoryDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), ), ), Effect.map(normalizeRepositoryCloneUrls), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index c1aa8680b26b..9e8a68295667 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -70,11 +70,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = it.effect("adds safe request context while retaining GitHub CLI causes", () => Effect.gen(function* () { - const cause = new GitHubCli.GitHubCliError({ - operation: "execute", + const cause = new GitHubCli.GitHubPullRequestNotFoundError({ command: "gh", cwd: "/repo", - detail: "Pull request not found. Check the PR number or URL and try again.", cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 60298888e6c0..b5d5d3a55f8f 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -158,23 +158,17 @@ export const make = Effect.gen(function* () { })), ) : Effect.fail( - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", + new GitHubCli.GitHubChangeRequestListDecodeError({ command: "gh", cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: "GitHub CLI returned invalid change request JSON.", cause: decoded.failure, }), ), ), ); }), - Effect.catchTags({ - GitHubCliError: (error) => + Effect.mapError( + (error) => new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", @@ -186,7 +180,7 @@ export const make = Effect.gen(function* () { detail: error.detail, cause: error, }), - }), + ), ); }; From e4a84a348e59d59e03bca15baa627aed99aec790 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:20:01 -0700 Subject: [PATCH 67/80] [codex] Structure release output failures (#3470) Co-authored-by: codex --- scripts/resolve-nightly-release.test.ts | 26 ++++++ scripts/resolve-nightly-release.ts | 48 +++++++++-- scripts/resolve-previous-release-tag.test.ts | 89 +++++++++++++++++++- scripts/resolve-previous-release-tag.ts | 60 ++++++++++--- 4 files changed, 205 insertions(+), 18 deletions(-) diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 18d6a2a2ed73..dda1e7081be0 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -10,6 +12,7 @@ import { resolveNightlyBaseVersion, resolveNightlyReleaseMetadata, resolveNightlyTargetVersion, + writeNightlyReleaseOutput, } from "./resolve-nightly-release.ts"; it("strips prerelease and build metadata when deriving the nightly base version", () => { @@ -49,6 +52,29 @@ it("derives nightly metadata including the short commit sha in the release name" ); }); +it.effect("preserves the GITHUB_OUTPUT configuration cause", () => { + const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890"); + const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" }); + + return Effect.gen(function* () { + const configError = yield* writeNightlyReleaseOutput(metadata, true).pipe( + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.make(() => Effect.fail(configCause)), + ), + Effect.flip, + ); + + if (configError._tag !== "NightlyReleaseGitHubOutputConfigError") { + return assert.fail(`Unexpected error: ${configError._tag}`); + } + assert.instanceOf(configError.cause, Config.ConfigError); + assert.strictEqual(configError.cause.cause, configCause); + assert.notInclude(configError.message, configCause.message); + }); +}); + it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { it.effect("preserves desktop package read context and its platform cause", () => Effect.gen(function* () { diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index adad8c6f4f87..5b42f931d7b5 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; -interface NightlyReleaseMetadata { +export interface NightlyReleaseMetadata { readonly baseVersion: string; readonly version: string; readonly tag: string; @@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for nightly release metadata."; + } +} + +export class NightlyReleaseGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append nightly release metadata to ${this.outputPath}.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -120,7 +143,7 @@ export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(functi return yield* resolveNightlyTargetVersion(packageJson.version); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* ( metadata: NightlyReleaseMetadata, writeGithubOutput: boolean, ) { @@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* ( ] as const; if (writeGithubOutput) { - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputConfigError({ + cause, + }), + ), + ); const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join(""); - yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }); + yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); } else { for (const [key, value] of entries) { yield* Console.log(`${key}=${value}`); @@ -172,7 +210,7 @@ const command = Command.make( ({ date, runNumber, sha, githubOutput, root }) => readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), - Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)), + Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)), ), ).pipe(Command.withDescription("Resolve nightly release version metadata.")); diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts index a9c4832c26ad..5fe06d2af54b 100644 --- a/scripts/resolve-previous-release-tag.test.ts +++ b/scripts/resolve-previous-release-tag.test.ts @@ -1,11 +1,17 @@ import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; +import { + listGitTags, + resolvePreviousReleaseTag, + writePreviousReleaseTagOutput, +} from "./resolve-previous-release-tag.ts"; const encoder = new TextEncoder(); @@ -13,6 +19,8 @@ function mockHandle(options: { readonly exitCode: number; readonly stdout?: string; readonly stderr?: string; + readonly stdoutError?: PlatformError.PlatformError; + readonly stderrError?: PlatformError.PlatformError; }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -21,8 +29,12 @@ function mockHandle(options: { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode(options.stdout ?? "")), - stderr: Stream.make(encoder.encode(options.stderr ?? "")), + stdout: options.stdoutError + ? Stream.fail(options.stdoutError) + : Stream.make(encoder.encode(options.stdout ?? "")), + stderr: options.stderrError + ? Stream.fail(options.stderrError) + : Stream.make(encoder.encode(options.stderr ?? "")), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () => }); }); +it.effect("distinguishes stdout and stderr read failures", () => + Effect.gen(function* () { + for (const [stream, operation] of [ + ["stdout", "read-stdout"], + ["stderr", "read-stderr"], + ] as const) { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: stream, + description: `${stream} unavailable`, + }); + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + exitCode: 0, + ...(stream === "stdout" ? { stdoutError: cause } : { stderrError: cause }), + }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, operation); + assert.strictEqual(error.cause, cause); + } + }), +); + it.effect("reports git tag non-zero exits without manufacturing a cause", () => Effect.gen(function* () { const error = yield* listGitTags("/repo").pipe( @@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () => assert.notProperty(error, "stderr"); }), ); + +it.effect("preserves the GITHUB_OUTPUT append path and exact cause", () => { + const outputPath = "/tmp/previous-tag-github-output"; + const appendCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + pathOrDescriptor: outputPath, + }); + + return Effect.gen(function* () { + const appendError = yield* writePreviousReleaseTagOutput("v1.2.3", true).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: () => Effect.fail(appendCause), + }), + ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { GITHUB_OUTPUT: outputPath } }), + ), + Effect.flip, + ); + + if (appendError._tag !== "PreviousReleaseTagGitHubOutputAppendError") { + return assert.fail(`Unexpected error: ${appendError._tag}`); + } + assert.equal(appendError.outputPath, outputPath); + assert.strictEqual(appendError.cause, appendCause); + assert.notProperty(appendError, "contents"); + assert.notInclude(appendError.message, appendCause.message); + }); +}); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 83b6e65d1e13..7acc6f456b85 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -26,9 +26,11 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for the previous release tag."; + } +} + +export class PreviousReleaseTagGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append the previous release tag to ${this.outputPath}.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd (cause) => new ReleaseTagListProcessError({ ...context, - operation: "communicate", + operation: "read-stdout", cause, }), ), @@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd (cause) => new ReleaseTagListProcessError({ ...context, - operation: "communicate", + operation: "read-stderr", cause, }), ), @@ -280,7 +305,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* ( previousTag: string | undefined, writeGithubOutput: boolean, ) { @@ -288,8 +313,23 @@ const writeOutput = Effect.fn("writeOutput")(function* ( if (writeGithubOutput) { const fs = yield* FileSystem.FileSystem; - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); - yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputConfigError({ + cause, + }), + ), + ); + yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); return; } @@ -313,7 +353,7 @@ const command = Command.make( ({ channel, currentTag, githubOutput }) => listGitTags().pipe( Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), - Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), + Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)), ), ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); From cebbe6ff193c9598bf77c4f673a933db34ed7bcb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 19:20:04 -0700 Subject: [PATCH 68/80] [codex] Preserve child process termination context (#3469) Co-authored-by: codex --- .../effect-acp/src/_internal/stdio.test.ts | 45 +++++++++++++++++ packages/effect-acp/src/_internal/stdio.ts | 10 +++- packages/effect-acp/src/errors.ts | 2 + .../src/_internal/stdio.test.ts | 48 +++++++++++++++++++ .../src/_internal/stdio.ts | 10 +++- .../effect-codex-app-server/src/errors.ts | 2 + 6 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 packages/effect-acp/src/_internal/stdio.test.ts create mode 100644 packages/effect-codex-app-server/src/_internal/stdio.test.ts diff --git a/packages/effect-acp/src/_internal/stdio.test.ts b/packages/effect-acp/src/_internal/stdio.test.ts new file mode 100644 index 000000000000..8a4171f7a8dd --- /dev/null +++ b/packages/effect-acp/src/_internal/stdio.test.ts @@ -0,0 +1,45 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as AcpError from "../errors.ts"; +import { makeTerminationError } from "./stdio.ts"; + +describe("ACP child process termination", () => { + it.effect("retains the process identifier with the exit code", () => + Effect.gen(function* () { + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(41), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(7)), + }); + + assert.instanceOf(error, AcpError.AcpProcessExitedError); + assert.equal(error.pid, 41); + assert.equal(error.code, 7); + assert.equal(error.message, "ACP process exited with code 7"); + }), + ); + + it.effect("retains the process identifier and exact exit-status cause", () => + Effect.gen(function* () { + const rootCause = new Error("private process diagnostics"); + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: rootCause, + }); + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(42), + exitCode: Effect.fail(cause), + }); + + assert.instanceOf(error, AcpError.AcpTransportError); + assert.equal(error.pid, 42); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "ACP transport operation read-process-exit-status failed."); + assert.notInclude(error.message, rootCause.message); + }), + ); +}); diff --git a/packages/effect-acp/src/_internal/stdio.ts b/packages/effect-acp/src/_internal/stdio.ts index 393a1c591cbf..87af633637ae 100644 --- a/packages/effect-acp/src/_internal/stdio.ts +++ b/packages/effect-acp/src/_internal/stdio.ts @@ -44,14 +44,20 @@ export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () { }; }); +type ChildProcessTerminationHandle = Pick< + ChildProcessSpawner.ChildProcessHandle, + "exitCode" | "pid" +>; + export const makeTerminationError = ( - handle: ChildProcessSpawner.ChildProcessHandle, + handle: ChildProcessTerminationHandle, ): Effect.Effect => Effect.match(handle.exitCode, { onFailure: (cause) => new AcpError.AcpTransportError({ operation: "read-process-exit-status", + pid: handle.pid, cause, }), - onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }), + onSuccess: (code) => new AcpError.AcpProcessExitedError({ code, pid: handle.pid }), }); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 3fe0a4690013..f92568a14834 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -91,6 +91,7 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass { + it.effect("retains the process identifier with the exit code", () => + Effect.gen(function* () { + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(51), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9)), + }); + + assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError); + assert.equal(error.pid, 51); + assert.equal(error.code, 9); + assert.equal(error.message, "Codex App Server process exited with code 9"); + }), + ); + + it.effect("retains the process identifier and exact exit-status cause", () => + Effect.gen(function* () { + const rootCause = new Error("private process diagnostics"); + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: rootCause, + }); + const error = yield* makeTerminationError({ + pid: ChildProcessSpawner.ProcessId(52), + exitCode: Effect.fail(cause), + }); + + assert.instanceOf(error, CodexError.CodexAppServerTransportError); + assert.equal(error.pid, 52); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Codex App Server transport operation 'read-process-exit-status' failed.", + ); + assert.notInclude(error.message, rootCause.message); + }), + ); +}); diff --git a/packages/effect-codex-app-server/src/_internal/stdio.ts b/packages/effect-codex-app-server/src/_internal/stdio.ts index 9167129db5c5..312022824cb3 100644 --- a/packages/effect-codex-app-server/src/_internal/stdio.ts +++ b/packages/effect-codex-app-server/src/_internal/stdio.ts @@ -44,14 +44,20 @@ export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () { }; }); +type ChildProcessTerminationHandle = Pick< + ChildProcessSpawner.ChildProcessHandle, + "exitCode" | "pid" +>; + export const makeTerminationError = ( - handle: ChildProcessSpawner.ChildProcessHandle, + handle: ChildProcessTerminationHandle, ): Effect.Effect => Effect.match(handle.exitCode, { onFailure: (cause) => new CodexError.CodexAppServerTransportError({ operation: "read-process-exit-status", + pid: handle.pid, cause, }), - onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code }), + onSuccess: (code) => new CodexError.CodexAppServerProcessExitedError({ code, pid: handle.pid }), }); diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 3826a0992293..f0e0945d352b 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -146,6 +146,7 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 20:19:59 -0700 Subject: [PATCH 69/80] [codex] Structure VCS process boundary errors (#3476) Co-authored-by: codex --- apps/server/src/sourceControl/GitLabCli.ts | 5 +- apps/server/src/vcs/VcsProcess.test.ts | 65 ++++++++++++ apps/server/src/vcs/VcsProcess.ts | 26 ++++- packages/contracts/src/vcs.ts | 109 +++++++++++---------- 4 files changed, 145 insertions(+), 60 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 3e3bbe742c16..a2926afd0efb 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -133,7 +133,10 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass new GitLabCliCommandError({ ...context, cause }), - VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index e13120b1c57f..675d20cb82c9 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -11,6 +11,7 @@ import { VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; +import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -24,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const provideLive = (effect: Effect.Effect) => effect.pipe(Effect.provide(liveLayer)); +const baseInput = { + operation: "test.process-boundary", + command: "git", + args: ["status", "--short"], + cwd: "/workspace", +} satisfies VcsProcess.VcsProcessInput; + +const captureProcessResult = ( + result: Effect.Effect, +) => + VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: () => result }), + ), + Effect.flatMap((service) => service.run(baseInput)), + Effect.flip, + ); + describe("VcsProcess.run", () => { it.effect("collects stdout", () => Effect.gen(function* () { @@ -141,6 +161,51 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("preserves real boundary causes without manufacturing structural ones", () => + Effect.gen(function* () { + const cause = new Error("secret stdin failure"); + const error = yield* captureProcessResult( + Effect.fail( + new ProcessRunner.ProcessStdinError({ + command: baseInput.command, + argumentCount: baseInput.args.length, + cwd: baseInput.cwd, + stdinBytes: 47, + cause, + }), + ), + ); + + expect(error).toMatchObject({ + _tag: "VcsProcessStdinWriteError", + operation: baseInput.operation, + stdinBytes: 47, + cause, + }); + expect(error.message).not.toContain(cause.message); + + const missingExitCodeError = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + expect(missingExitCodeError).toMatchObject({ + _tag: "VcsProcessMissingExitCodeError", + operation: baseInput.operation, + command: baseInput.command, + cwd: baseInput.cwd, + argumentCount: baseInput.args.length, + }); + expect(missingExitCodeError).not.toHaveProperty("cause"); + }), + ); + it.effect("returns output when non-zero exits are allowed", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 8103c7306a0a..52db6f9b1fb2 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -5,11 +5,14 @@ import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - VcsOutputDecodeError, type VcsError, VcsProcessExitError, type VcsProcessExitFailureKind, + VcsProcessMissingExitCodeError, + VcsProcessOutputLimitError, + VcsProcessOutputReadError, VcsProcessSpawnError, + VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as ProcessRunner from "../processRunner.ts"; @@ -114,19 +117,32 @@ export const make = Effect.gen(function* () { ProcessSpawnError: (error) => VcsProcessSpawnError.fromProcessSpawnError(baseError, error), ProcessOutputLimitError: (error) => - VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), + new VcsProcessOutputLimitError({ + ...baseError, + stream: error.stream, + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }), ProcessTimeoutError: (error) => VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), ProcessStdinError: (error) => - VcsOutputDecodeError.fromProcessStdinError(baseError, error), + new VcsProcessStdinWriteError({ + ...baseError, + stdinBytes: error.stdinBytes, + cause: error.cause, + }), ProcessReadError: (error) => - VcsOutputDecodeError.fromProcessReadError(baseError, error), + new VcsProcessOutputReadError({ + ...baseError, + stream: error.stream, + cause: error.cause, + }), }), ), ); if (result.code === null) { - return yield* VcsOutputDecodeError.missingExitCode(baseError); + return yield* new VcsProcessMissingExitCodeError(baseError); } if (!input.allowNonZeroExit && result.code !== 0) { diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 728cef3974fe..c1090f4f39a3 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -69,20 +69,6 @@ export interface VcsProcessSpawnFailure { readonly cause: unknown; } -export interface VcsProcessStdinFailure { - readonly cause: unknown; -} - -export interface VcsProcessReadFailure { - readonly stream: "stdout" | "stderr" | "exitCode"; - readonly cause: unknown; -} - -export interface VcsProcessOutputLimitFailure { - readonly stream: "stdout" | "stderr"; - readonly maxBytes: number; -} - export interface VcsProcessTimeoutFailure { readonly timeoutMs: number; } @@ -189,58 +175,70 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( - "VcsOutputDecodeError", +const VcsProcessBoundaryErrorFields = { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + argumentCount: Schema.optional(NonNegativeInt), +}; + +export class VcsProcessStdinWriteError extends Schema.TaggedErrorClass()( + "VcsProcessStdinWriteError", { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - argumentCount: Schema.optional(NonNegativeInt), - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...VcsProcessBoundaryErrorFields, + stdinBytes: NonNegativeInt, + cause: Schema.Defect(), }, ) { override get message(): string { - return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } - - static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: "failed to write process stdin", - cause: error.cause, - }); + return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: - error.stream === "exitCode" - ? "failed to read process exit code" - : `failed to read process ${error.stream}`, - cause: error.cause, - }); +export class VcsProcessOutputReadError extends Schema.TaggedErrorClass()( + "VcsProcessOutputReadError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr", "exitCode"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `VCS process failed to read ${this.stream} in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessOutputLimitError( - context: VcsProcessErrorContext, - error: VcsProcessOutputLimitFailure, - ) { - return new VcsOutputDecodeError({ - ...context, - detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, - }); +export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass()( + "VcsProcessOutputLimitError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr"]), + maxBytes: NonNegativeInt, + observedBytes: NonNegativeInt, + }, +) { + override get message(): string { + return `VCS process ${this.stream} produced ${this.observedBytes} bytes in ${this.operation}: ${this.command} (${this.cwd}), exceeding the ${this.maxBytes} byte limit`; } +} - static missingExitCode(context: VcsProcessErrorContext) { - return new VcsOutputDecodeError({ - ...context, - detail: "process completed without an exit code", - }); +export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass()( + "VcsProcessMissingExitCodeError", + VcsProcessBoundaryErrorFields, +) { + override get message(): string { + return `VCS process completed without an exit code in ${this.operation}: ${this.command} (${this.cwd})`; } } +export const VcsOutputDecodeError = Schema.Union([ + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, +]); +export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type; + export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( "VcsRepositoryDetectionError", { @@ -272,7 +270,10 @@ export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, VcsProcessTimeoutError, - VcsOutputDecodeError, + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, ]); From e3970e755b0da65c3fd83a985cede1840164ae0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:01 -0700 Subject: [PATCH 70/80] [codex] Preserve APNs delivery failure context (#3475) Co-authored-by: codex --- .../src/agentActivity/ApnsDeliveries.test.ts | 37 ++++++- .../relay/src/agentActivity/ApnsDeliveries.ts | 104 +++++++++++++++--- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 207f4b214176..da3c39cfa714 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -7,7 +7,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Redacted from "effect/Redacted"; +import * as References from "effect/References"; import { FetchHttpClient, HttpClient, @@ -629,6 +631,17 @@ describe("ApnsDeliveries", () => { it.effect("processes signed jobs through APNs and records attempts", () => { const attempts: Array = []; + const transportErrors: Array = []; + const logger = Logger.make(({ fiber }) => { + const annotation = fiber.getRef(References.CurrentLogAnnotations).error; + if (!Redacted.isRedacted(annotation)) { + return; + } + const error = Redacted.value(annotation); + if (ApnsDeliveries.isApnsDeliveryTransportError(error)) { + transportErrors.push(error); + } + }); const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_update", userId: target.user_id, @@ -657,7 +670,29 @@ describe("ApnsDeliveries", () => { token: "activity-token", }, ]); - }).pipe(Effect.provide(makeLayer({ attempts }))); + expect(transportErrors).toHaveLength(1); + const error = transportErrors[0]!; + expect(error).toMatchObject({ + deviceId: target.device_id, + kind: "live_activity_update", + sourceJobId: "job-1", + apnsErrorTag: "ApnsJwtSigningError", + requestStage: null, + }); + expect(error.cause).toBeInstanceOf(ApnsClient.ApnsJwtSigningError); + expect(error.cause).toMatchObject({ + teamId: "team-id", + keyId: "key-id", + }); + expect((error.cause as ApnsClient.ApnsJwtSigningError).cause).toBeDefined(); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer({ attempts }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); }); it.effect("processes signed push notification jobs through APNs and records attempts", () => { diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 6c714d1d56d6..c83eaf34f2eb 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -7,12 +7,14 @@ import type { import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema, RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema, + RelayDeliveryKind as RelayDeliveryKindSchema, } from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { @@ -87,6 +89,28 @@ export class ApnsDeliveryJobClaimInFlight extends Schema.TaggedErrorClass()( + "ApnsDeliveryTransportError", + { + deviceId: Schema.String, + kind: RelayDeliveryKindSchema, + sourceJobId: Schema.NullOr(Schema.String), + apnsErrorTag: Schema.Literals([ + "ApnsJwtEncodingError", + "ApnsJwtSigningError", + "ApnsHttpRequestError", + ]), + requestStage: Schema.NullOr(Schema.Literals(["send", "read-response"])), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `APNs ${this.kind} delivery failed for device ${this.deviceId}.`; + } +} + +export const isApnsDeliveryTransportError = Schema.is(ApnsDeliveryTransportError); + const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), ); @@ -302,6 +326,42 @@ function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) { }; } +const recoverApnsDeliveryTransportError = ( + input: { + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly sourceJobId: string | null; + }, + cause: Apns.ApnsError, +): Effect.Effect => { + const error = new ApnsDeliveryTransportError({ + deviceId: input.deviceId, + kind: input.kind, + sourceJobId: input.sourceJobId, + apnsErrorTag: cause._tag, + requestStage: cause._tag === "ApnsHttpRequestError" ? cause.stage : null, + cause, + }); + return Effect.logError(error.message).pipe( + Effect.annotateLogs({ + error: Redacted.make(error, { label: error._tag }), + "error.type": error._tag, + "error.apns_error_tag": error.apnsErrorTag, + ...(error.requestStage === null ? {} : { "error.request_stage": error.requestStage }), + ...(error.stack === undefined ? {} : { "error.stack": error.stack }), + "relay.mobile.device_id": error.deviceId, + "relay.delivery.kind": error.kind, + ...(error.sourceJobId === null ? {} : { "relay.delivery.job_id": error.sourceJobId }), + }), + Effect.as({ + ok: false, + status: 0, + reason: cause.message, + apnsId: null, + }), + ); +}; + interface LiveActivityDeliveryTarget { readonly user_id: string; readonly device_id: string; @@ -440,6 +500,15 @@ export const make = Effect.gen(function* () { { ...input, aggregate } as SendLiveActivityDeliveryInput, now, ); + const recoverTransportError = (cause: Apns.ApnsError) => + recoverApnsDeliveryTransportError( + { + deviceId: input.target.device_id, + kind: input.kind, + sourceJobId: input.sourceJobId ?? null, + }, + cause, + ); if (input.sourceJobId) { const claim = yield* attempts.claimSourceJob({ userId: input.target.user_id, @@ -476,14 +545,11 @@ export const make = Effect.gen(function* () { issuedAtUnixSeconds: epochSeconds, }) .pipe( - Effect.catch((error) => - Effect.succeed({ - ok: false, - status: 0, - reason: error.message, - apnsId: null, - }), - ), + Effect.catchTags({ + ApnsJwtEncodingError: recoverTransportError, + ApnsJwtSigningError: recoverTransportError, + ApnsHttpRequestError: recoverTransportError, + }), ); if (result.ok) { yield* liveActivities.markDelivery({ @@ -551,6 +617,15 @@ export const make = Effect.gen(function* () { token: input.token, notification, }); + const recoverTransportError = (cause: Apns.ApnsError) => + recoverApnsDeliveryTransportError( + { + deviceId: input.target.device_id, + kind: "push_notification", + sourceJobId: input.sourceJobId ?? null, + }, + cause, + ); if (input.sourceJobId) { const claim = yield* attempts.claimSourceJob({ userId: input.target.user_id, @@ -593,14 +668,11 @@ export const make = Effect.gen(function* () { issuedAtUnixSeconds: epochSeconds, }) .pipe( - Effect.catch((error) => - Effect.succeed({ - ok: false, - status: 0, - reason: error.message, - apnsId: null, - }), - ), + Effect.catchTags({ + ApnsJwtEncodingError: recoverTransportError, + ApnsJwtSigningError: recoverTransportError, + ApnsHttpRequestError: recoverTransportError, + }), ); if (isPermanentApnsTokenFailure(result)) { yield* liveActivities.invalidateDeliveryToken({ From 6155f5cf64e9f26d10f3c912985f8ca09f9c3fef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:04 -0700 Subject: [PATCH 71/80] [codex] Preserve VCS project config error causes (#3474) Co-authored-by: codex --- apps/server/src/vcs/VcsProjectConfig.test.ts | 35 +++++++++++--------- apps/server/src/vcs/VcsProjectConfig.ts | 15 +++++---- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 5fe5dcc75640..04f7fcffcda0 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -96,16 +96,19 @@ describe("VcsProjectConfig", () => { const kind = yield* config.resolveKind({ cwd }); assert.equal(kind, "jj"); - const [message, context] = messages[0] as [string, Record]; const failedCandidate = path.join(cwd, ".t3code", "vcs.json"); - assert.equal(message, "Failed to inspect VCS project config at " + failedCandidate + "."); - assert.deepInclude(context, { + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to inspect VCS project config at " + failedCandidate + ".", + ); + assert.deepInclude(error, { operation: "inspect", cwd, configPath: failedCandidate, - errorTag: "VcsProjectConfigError", + _tag: "VcsProjectConfigError", }); - assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); @@ -146,18 +149,19 @@ describe("VcsProjectConfig", () => { const kind = yield* config.resolveKind({ cwd: root }); assert.equal(kind, "auto"); - const [message, context] = messages[0] as [string, Record]; + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); assert.equal( - message, + error.message, "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".", ); - assert.deepInclude(context, { + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + assert.deepInclude(error, { operation: "decode", cwd: root, configPath: path.join(configDir, "vcs.json"), - errorTag: "VcsProjectConfigError", + _tag: "VcsProjectConfigError", }); - assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); @@ -182,15 +186,16 @@ describe("VcsProjectConfig", () => { const kind = yield* config.resolveKind({ cwd: root }); assert.equal(kind, "auto"); - const [message, context] = messages[0] as [string, Record]; - assert.equal(message, "Failed to read VCS project config at " + configPath + "."); - assert.deepInclude(context, { + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal(error.message, "Failed to read VCS project config at " + configPath + "."); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + assert.deepInclude(error, { operation: "read", cwd: root, configPath, - errorTag: "VcsProjectConfigError", + _tag: "VcsProjectConfigError", }); - assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index bd8f45150073..6abce9a3ef31 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -55,13 +55,14 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto } const logVcsProjectConfigError = (error: VcsProjectConfigError) => - Effect.logWarning(error.message, { - operation: error.operation, - cwd: error.cwd, - configPath: error.configPath, - errorTag: error._tag, - stack: error.stack, - }); + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + cwd: error.cwd, + configPath: error.configPath, + errorTag: error._tag, + }), + ); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; From 90074e359f360bd7983661757ccf995524b9dbaa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:07 -0700 Subject: [PATCH 72/80] [codex] Preserve desktop update state causes (#3473) Co-authored-by: codex --- apps/web/src/state/desktopUpdate.test.ts | 11 ++++++++--- apps/web/src/state/desktopUpdate.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index f6b3081a80fb..a2bcbd19a33f 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -3,7 +3,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { createDesktopUpdateStateAtom } from "./desktopUpdate"; +import { createDesktopUpdateStateAtom, DesktopUpdateStateReadError } from "./desktopUpdate"; const baseState: DesktopUpdateState = { enabled: true, @@ -117,8 +117,13 @@ describe("desktopUpdateStateAtom", () => { errorTag: "DesktopUpdateStateReadError", attemptCount: 3, }); - expect(errorContext).not.toHaveProperty("error"); - expect(errorContext).not.toHaveProperty("cause"); + const loggedError = (errorContext as { readonly error: unknown }).error; + expect(loggedError).toBeInstanceOf(DesktopUpdateStateReadError); + expect(loggedError).toMatchObject({ + _tag: "DesktopUpdateStateReadError", + attemptCount: 3, + }); + expect((loggedError as DesktopUpdateStateReadError).cause).toBe(cause); listener?.(baseState); await vi.waitFor(() => { diff --git a/apps/web/src/state/desktopUpdate.ts b/apps/web/src/state/desktopUpdate.ts index 75764410625c..a7b9ed483b65 100644 --- a/apps/web/src/state/desktopUpdate.ts +++ b/apps/web/src/state/desktopUpdate.ts @@ -59,9 +59,9 @@ export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridg Effect.catchTags({ DesktopUpdateStateReadError: (error) => Effect.logError(error.message, { + error, errorTag: error._tag, attemptCount: error.attemptCount, - stack: error.stack, }).pipe(Effect.as(null)), }), ); From 8c1605b38327176bae94c33bbc6de86e8b09fe02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:09 -0700 Subject: [PATCH 73/80] [codex] Structure OpenCode text generation failures (#3472) Co-authored-by: codex --- .../OpenCodeTextGeneration.test.ts | 145 +++++++-- .../textGeneration/OpenCodeTextGeneration.ts | 296 ++++++++++++++---- 2 files changed, 353 insertions(+), 88 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index f6d9c133f383..558a8663b64d 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,4 +1,4 @@ -import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -11,8 +11,8 @@ import { beforeEach, expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; import * as TextGeneration from "./TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; const runtimeMock = { state: { @@ -20,8 +20,11 @@ const runtimeMock = { promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], + sessionCreateError: undefined as unknown, + sessionResult: undefined as { data?: { id: string } } | undefined, + promptRequestError: undefined as unknown, promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | { data?: { info?: { error?: unknown }; parts?: Array } } | undefined, }, reset() { @@ -29,6 +32,9 @@ const runtimeMock = { this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.sessionCreateError = undefined; + this.state.sessionResult = undefined; + this.state.promptRequestError = undefined; this.state.promptResult = undefined; }, }; @@ -61,12 +67,20 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => ({ data: { id: `${baseUrl}/session` } }), + create: async () => { + if (runtimeMock.state.sessionCreateError !== undefined) { + throw runtimeMock.state.sessionCreateError; + } + return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; + }, prompt: async () => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + if (runtimeMock.state.promptRequestError !== undefined) { + throw runtimeMock.state.promptRequestError; + } return ( runtimeMock.state.promptResult ?? { data: { @@ -99,6 +113,13 @@ const DEFAULT_TEST_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("opencode"), model: "openai/gpt-5", }; +const DEFAULT_COMMIT_MESSAGE_INPUT = { + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, +}; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; @@ -142,7 +163,7 @@ function withOpenCodeTextGeneration( effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const textGeneration = yield* makeOpenCodeTextGeneration(settings); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -221,22 +242,99 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); - it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + it.effect("preserves the SDK cause when session creation fails", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - runtimeMock.state.promptResult = { data: {} }; + const sdkCause = new Error("session endpoint unavailable"); + runtimeMock.state.sessionCreateError = sdkCause; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode session.create request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("reports a missing session payload without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = {}; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("preserves the SDK cause and request context when prompting fails", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("prompt endpoint unavailable"); + runtimeMock.state.promptRequestError = sdkCause; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.prompt request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("returns a typed empty-output error for malformed and blank response parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [null, { type: "tool" }, { type: "text", text: " " }], + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("OpenCode returned empty output."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationEmptyOutputError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + responsePartCount: 3, + textPartCount: 1, + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); @@ -289,16 +387,21 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { }; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("Model did not produce structured output"); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptResponseError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + providerErrorName: "StructuredOutputError", + providerMessage: "Model did not produce structured output", + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index f59e76942134..1f94f970692c 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { + NonNegativeInt, TextGenerationError, type ChatAttachment, type ModelSelection, @@ -33,11 +34,106 @@ import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; -function getOpenCodePromptErrorMessage(error: unknown): string | null { +const OpenCodeTextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); + +type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; + +const openCodeTextGenerationErrorContext = { + operation: OpenCodeTextGenerationOperation, + cwd: Schema.String, +}; + +export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionRequestError", + { + ...openCodeTextGenerationErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; + } +} + +export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionPayloadError", + openCodeTextGenerationErrorContext, +) { + override get message(): string { + return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; + } +} + +const openCodePromptErrorContext = { + ...openCodeTextGenerationErrorContext, + sessionId: Schema.String, + providerId: Schema.String, + modelId: Schema.String, +}; + +export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptRequestError", + { + ...openCodePromptErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; + } +} + +export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptResponseError", + { + ...openCodePromptErrorContext, + providerErrorName: Schema.optional(Schema.String), + providerMessage: Schema.String, + }, +) { + override get message(): string { + const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; + return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; + } +} + +export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationEmptyOutputError", + { + ...openCodePromptErrorContext, + responsePartCount: NonNegativeInt, + textPartCount: NonNegativeInt, + }, +) { + override get message(): string { + return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; + } +} + +interface OpenCodePromptFailure { + readonly name?: string; + readonly message: string; +} + +interface OpenCodeTextPart { + readonly type: "text"; + readonly text: string; +} + +function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { if (!error || typeof error !== "object") { return null; } + const name = + "name" in error && typeof error.name === "string" && error.name.trim().length > 0 + ? error.name.trim() + : undefined; const message = "data" in error && error.data && @@ -47,31 +143,34 @@ function getOpenCodePromptErrorMessage(error: unknown): string | null { ? error.data.message.trim() : ""; if (message.length > 0) { - return message; + return { + ...(name ? { name } : {}), + message, + }; } - if ("name" in error && typeof error.name === "string") { - const name = error.name.trim(); - return name.length > 0 ? name : null; + if (name) { + return { name, message: name }; } return null; } +function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { + return ( + part !== null && + typeof part === "object" && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ); +} + function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { return (parts ?? []) - .flatMap((part) => { - if (!part || typeof part !== "object") { - return []; - } - if (!("type" in part) || part.type !== "text") { - return []; - } - if (!("text" in part) || typeof part.text !== "string") { - return []; - } - return [part.text]; - }) + .filter(isOpenCodeTextPart) + .map((part) => part.text) .join("") .trim(); } @@ -260,11 +359,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ); const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; + readonly operation: OpenCodeTextGenerationOperation; readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; @@ -285,54 +380,121 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); - const runAgainstServer = (server: Pick) => - Effect.tryPromise({ - try: async () => { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( + function* (server: Pick) { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const session = yield* Effect.tryPromise({ + try: () => + client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }), + catch: (cause) => + new OpenCodeTextGenerationSessionRequestError({ + operation: input.operation, + cwd: input.cwd, + cause, + }), + }); + if (!session.data) { + return yield* new OpenCodeTextGenerationSessionPayloadError({ + operation: input.operation, + cwd: input.cwd, }); - const session = await client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const promptContext = { + operation: input.operation, + cwd: input.cwd, + sessionId: session.data.id, + providerId: parsedModel.providerID, + modelId: parsedModel.modelID, + }; + + const result = yield* Effect.tryPromise({ + try: () => + client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + if (promptFailure) { + return yield* new OpenCodeTextGenerationPromptResponseError({ + ...promptContext, + ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), + providerMessage: promptFailure.message, }); - if (!session.data) { - throw new Error("OpenCode session.create returned no session payload."); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue( - input.modelSelection, - "variant", - ); - - const result = await client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], + } + const responseParts = result.data?.parts ?? []; + const rawText = getOpenCodeTextResponse(responseParts); + if (rawText.length === 0) { + return yield* new OpenCodeTextGenerationEmptyOutputError({ + ...promptContext, + responsePartCount: responseParts.length, + textPartCount: responseParts.filter(isOpenCodeTextPart).length, }); - const info = result.data?.info; - const errorMessage = getOpenCodePromptErrorMessage(info?.error); - if (errorMessage) { - throw new Error(errorMessage); - } - const rawText = getOpenCodeTextResponse(result.data?.parts); - if (rawText.length === 0) { - throw new Error("OpenCode returned empty output."); - } - return rawText; - }, - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - }); + } + return rawText; + }, + Effect.catchTags({ + OpenCodeTextGenerationSessionRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create request failed.", + cause, + }), + ), + OpenCodeTextGenerationSessionPayloadError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create returned no session payload.", + cause, + }), + ), + OpenCodeTextGenerationPromptRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.prompt request failed.", + cause, + }), + ), + OpenCodeTextGenerationPromptResponseError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: cause.providerMessage, + cause, + }), + ), + OpenCodeTextGenerationEmptyOutputError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode returned empty output.", + cause, + }), + ), + }), + ); const rawOutput = openCodeSettings.serverUrl.length > 0 From 2a29de7502cdd02686bda7d12fb2671060fd1979 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:20:12 -0700 Subject: [PATCH 74/80] [codex] Structure primary auth validation failures (#3471) Co-authored-by: codex --- apps/web/src/authBootstrap.test.ts | 102 ++++++++++++---- apps/web/src/environments/primary/auth.ts | 122 +++++++++++-------- apps/web/src/environments/primary/context.ts | 1 - apps/web/src/environments/primary/index.ts | 2 + 4 files changed, 146 insertions(+), 81 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index c0713bfc059d..ced16c15f4ec 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -71,6 +71,18 @@ function installTestBrowser(url: string) { return testWindow; } +function installDesktopBootstrap() { + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; +} + function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -131,15 +143,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -289,6 +293,23 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("rejects a blank pairing token with a structured validation error", async () => { + const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const error = await submitServerAuthCredential(" ").then( + () => null, + (failure: unknown) => failure, + ); + + expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRequiredError", + providedLength: 3, + message: "Enter a pairing token to continue.", + }); + }); + it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { const cause = new EnvironmentAuthInvalidError({ code: "auth_invalid", @@ -299,7 +320,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.fail(cause), }); - const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } = + const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = await import("./environments/primary"); const error = await submitServerAuthCredential("bad-token").then( @@ -307,14 +328,13 @@ describe("resolveInitialServerAuthGateState", () => { (failure: unknown) => failure, ); expect(error).toMatchObject({ - _tag: "PrimaryEnvironmentRequestError", - operation: "exchange-bootstrap-credential", - status: 401, - detail: "Invalid pairing token. Check the token and try again.", + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + providedLength: 9, + message: "Invalid pairing token. Check the token and try again.", }); - expect(isPrimaryEnvironmentRequestError(error)).toBe(true); - if (!isPrimaryEnvironmentRequestError(error)) { - throw new Error("Expected a structured primary environment request error."); + expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); + if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { + throw new Error("Expected a structured rejected pairing credential error."); } expect(error.cause).toMatchObject({ _tag: "EnvironmentAuthInvalidError", @@ -325,6 +345,22 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); + it("derives primary request messages from structural request context", async () => { + const cause = new Error("private transport detail"); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-pairing-links", + cause, + }); + + expect(error.status).toBe(500); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Primary environment request failed during list-pairing-links (HTTP 500).", + ); + expect(error.message).not.toContain(cause.message); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const nextSession = sequence( @@ -337,15 +373,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -356,6 +384,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(3); }); + it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { + vi.useFakeTimers(); + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Timed out waiting for authenticated session after bootstrap.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("memoizes the authenticated gate state after the first successful read", async () => { const testApi = await installAuthApi({ session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 5cf7d2d34b71..96814b92b79b 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -40,7 +40,6 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass string; - readonly formatDetail?: (detail: string, status: number) => string; readonly pairingLinkId?: string; readonly sessionId?: string; }): PrimaryEnvironmentRequestError { const status = readHttpApiStatus(input.cause) ?? 500; - const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status)); return new PrimaryEnvironmentRequestError({ operation: input.operation, status, - detail: input.formatDetail?.(rawDetail, status) ?? rawDetail, ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), cause: input.cause, @@ -67,11 +62,59 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRejectedError", + { + providedLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid pairing token. Check the token and try again."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( + PrimaryEnvironmentPairingCredentialRejectedError, +); + +export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentAuthSessionTimeoutError", + { + timeoutMs: Schema.Number, + elapsedMs: Schema.Number, + }, +) { + override get message(): string { + return "Timed out waiting for authenticated session after bootstrap."; + } +} + +export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( + PrimaryEnvironmentAuthSessionTimeoutError, +); + +export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRequiredError", + { + providedLength: Schema.Number, + }, +) { + override get message(): string { + return "Enter a pairing token to continue."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( + PrimaryEnvironmentPairingCredentialRequiredError, +); + const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -151,7 +194,6 @@ export async function fetchSessionState(): Promise { throw PrimaryEnvironmentRequestError.fromCause({ operation: "fetch-session-state", cause: error, - fallbackMessage: (status) => `Failed to load server auth session state (${status}).`, }); } }); @@ -180,42 +222,6 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): } } -function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string { - if (!isEnvironmentHttpCommonError(error)) { - return fallbackMessage; - } - switch (error._tag) { - case "EnvironmentAuthInvalidError": - return error.reason === "missing_credential" - ? "Authentication required." - : "Invalid bootstrap credential."; - case "EnvironmentRequestInvalidError": - return error.reason === "invalid_scope" - ? "Requested token scope is invalid." - : "Requested scope exceeds the bootstrap credential grant."; - case "EnvironmentScopeRequiredError": - return `The authenticated token is missing required scope: ${error.requiredScope}.`; - case "EnvironmentOperationForbiddenError": - return "This operation is not allowed for the current session."; - case "EnvironmentInternalError": - return fallbackMessage; - } -} - -const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ - "Invalid bootstrap credential.", - "Unknown bootstrap credential.", -]); - -function toFriendlyBootstrapErrorMessage(status: number, message: string): string { - const trimmedMessage = message.trim(); - if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { - return "Invalid pairing token. Check the token and try again."; - } - - return trimmedMessage; -} - async function exchangeBootstrapCredential(credential: string): Promise { return retryTransientBootstrap(async () => { try { @@ -225,11 +231,19 @@ async function exchangeBootstrapCredential(credential: string): Promise `Failed to bootstrap auth session (${status}).`, - formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail), }); } }); @@ -244,8 +258,12 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { - throw new Error("Timed out waiting for authenticated session after bootstrap."); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { + throw new PrimaryEnvironmentAuthSessionTimeoutError({ + timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS, + elapsedMs, + }); } await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); @@ -323,7 +341,9 @@ async function bootstrapServerAuth(): Promise { export async function submitServerAuthCredential(credential: string): Promise { const trimmedCredential = credential.trim(); if (!trimmedCredential) { - throw new Error("Enter a pairing token to continue."); + throw new PrimaryEnvironmentPairingCredentialRequiredError({ + providedLength: credential.length, + }); } resolvedAuthenticatedGateState = null; @@ -355,7 +375,6 @@ export async function createServerPairingCredential(input?: { throw PrimaryEnvironmentRequestError.fromCause({ operation: "create-pairing-credential", cause: error, - fallbackMessage: (status) => `Failed to create pairing credential (${status}).`, }); } } @@ -396,7 +415,6 @@ export async function listServerPairingLinks(): Promise `Failed to load pairing links (${status}).`, }); } } @@ -413,7 +431,6 @@ export async function revokeServerPairingLink(id: string): Promise { operation: "revoke-pairing-link", pairingLinkId: id, cause: error, - fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`, }); } } @@ -446,7 +463,6 @@ export async function listServerClientSessions(): Promise< throw PrimaryEnvironmentRequestError.fromCause({ operation: "list-client-sessions", cause: error, - fallbackMessage: (status) => `Failed to load paired clients (${status}).`, }); } } @@ -465,7 +481,6 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi operation: "revoke-client-session", sessionId, cause: error, - fallbackMessage: (status) => `Failed to revoke client session (${status}).`, }); } } @@ -482,7 +497,6 @@ export async function revokeOtherServerClientSessions(): Promise { throw PrimaryEnvironmentRequestError.fromCause({ operation: "revoke-other-client-sessions", cause: error, - fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`, }); } } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index 40b6f68bd090..e1021a7feb4d 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -46,7 +46,6 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise `Failed to load server environment descriptor (${status}).`, }); } diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index e888560539db..58342d530547 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -16,10 +16,12 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentPairingCredentialRejectedError, isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentPairingCredentialRejectedError, PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, From 0debebafbb7d98a3c77d3e636fa54616a98d26da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:41:58 -0700 Subject: [PATCH 75/80] [codex] Structure thread archive blocked error (#3451) --- apps/web/src/hooks/useThreadActions.test.ts | 19 +++++++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 21 +++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/hooks/useThreadActions.test.ts diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts new file mode 100644 index 000000000000..c5385211591f --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -0,0 +1,19 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadArchiveBlockedError } from "./useThreadActions"; + +describe("ThreadArchiveBlockedError", () => { + it("keeps the blocked thread context with the fixed message", () => { + const error = new ThreadArchiveBlockedError({ + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }); + + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + }); + expect(error.message).toBe("Cannot archive a running thread."); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 357833480683..07655ad30d76 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,9 +4,9 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; @@ -27,9 +27,17 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; -export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{ - readonly message: string; -}> {} +export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadArchiveBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Cannot archive a running thread."; + } +} export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); @@ -89,7 +97,8 @@ export function useThreadActions() { return AsyncResult.failure( Cause.fail( new ThreadArchiveBlockedError({ - message: "Cannot archive a running thread.", + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, }), ), ); From 61f8d46fa2300e63c57229a6867c89b26c2f8f7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:43:41 -0700 Subject: [PATCH 76/80] [codex] Enforce Effect error handling conventions (#3380) --- .../check-run-agents/effect-service-conventions.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index d474c41d2fec..afbdc55ba608 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -48,7 +48,9 @@ Review changed TypeScript and directly affected call sites for the conventions b - Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. - `Schema.Defect()` is not a substitute for modeling a generic error: its tag, fields, or both must identify the failure structurally, and its `message` must not merely stringify an opaque cause. A semantically precise error tag may preserve a real `cause` without inventing a redundant singleton field when no additional variable context exists; still retain any real path, resource, request, or entity context available at the wrapping site. - Capture stable, serializable domain context such as the operation or stage, resource/path or entity identifier, and normalized category/status. Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error. Do not add a `detail` field that merely copies `cause.message` and then use it to construct the wrapper message. +- Keep direct error attributes and log annotations safe and bounded. Do not copy raw wire payloads, command arguments or output, signed URLs, credentials, query strings, fragments, selectors, or arbitrary defect text into `detail`, `reason`, `message`, or a parallel log payload. Preserve the exact underlying value only as `cause`; expose normalized categories plus lengths/counts and safe URL protocol/hostname diagnostics where useful. Logging a sanitized error must not reintroduce a removed legacy `detail` or serialized `cause` field beside it. - When translating or wrapping a real failure, preserve the immediate underlying error itself as `cause` alongside the structural fields so the complete error chain and stack remain available. If every construction wraps a failure, `cause` should be required; make it optional only when the same error can legitimately originate without an underlying failure. +- At a translation boundary, pass through an already structured domain error when it is part of the declared target error channel. Wrap only unknown or genuinely lower-level failures. A static factory or mapper may perform this classification when it is reused and keeps the policy next to the target error type. - Derive the wrapper's `message` exclusively from its stable structural attributes, never from `cause`, `cause.message`, or a stringified defect. Do not replace the immediate error with only `error.cause`, erase a structured upstream error into a string, or manufacture an `Error` merely to populate `cause`. Pure validation/domain errors created without an underlying failure do not need a cause. - Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Choose one coherent model: use distinct error classes and omit the redundant discriminator when callers or messages treat the failures as genuinely different, or use one service-level error with a multi-value operation discriminator and a generic message derived from that operation when the failures share the same semantics. - Treat an error message exposed through an HTTP/RPC response, persisted state, UI, or another caller-visible boundary as behavior. Preserve those messages during a structural refactor. Existing distinct caller-visible messages are evidence that the failures should normally remain distinct error tags without redundant singleton discriminators, rather than being collapsed into a generic operation error. @@ -56,6 +58,9 @@ Review changed TypeScript and directly affected call sites for the conventions b - Use `Schema.Union` of error classes when a shared schema, predicate, or helper type is useful. - Export direct schema predicates such as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a redundant function with the same signature. - Do not introduce a large `switch` or lookup table in an error's `message` getter to model failures that deserve separate error classes. +- Catch statically known tagged failures with `Effect.catchTags({ ... })`, including when handling only one tag. Do not use `catchIf` with a schema predicate merely to recover one or more known `_tag` variants, and do not use `catchTag`. `Effect.catch` is appropriate when the entire error channel is intentionally handled; `catchIf` remains appropriate for genuinely structural predicates such as inspecting an underlying platform error code. +- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`, including curried aliases used once with `mapError`. Construct the error at the failure boundary so its attributes and cause remain visible. Keep a mapper only when it performs real normalization, passes through existing domain errors, or adds reusable context/control flow. +- When a reusable error-to-error translation clearly belongs to the target error type, prefer a descriptive static factory on that error class over a detached production-side switch. Do not force a static method for one-off inline mappings. ## File layout and migrations @@ -73,4 +78,6 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Reporting -Report only concrete violations introduced or retained in the pull request's changed scope. Prefer precise inline comments on the smallest relevant line range and state the expected fix. A clear convention violation may fail the check. Do not fail for optional style preferences or unrelated legacy code. If there are no findings, report exactly `All clear`. +Report only concrete violations introduced or retained in the pull request's changed scope. Prefer precise inline comments on the smallest relevant line range and state the expected fix. A clear convention violation may fail the check. Do not fail for optional style preferences or unrelated legacy code. + +This check defaults to failure. When there are no findings, stop immediately and make the entire final response exactly `All clear` on one line. Do not add a title, explanation, punctuation, Markdown, JSON, or trailing analysis, and do not continue reasoning after deciding the review is clean. From 82a9bcc7f4e1c96bfb9cdc5acdd5be67151c707c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 20:45:17 -0700 Subject: [PATCH 77/80] [codex] add session context to credential errors (#3349) --- apps/server/src/auth/SessionStore.test.ts | 84 ++++- apps/server/src/auth/SessionStore.ts | 369 +++++++++++++--------- 2 files changed, 304 insertions(+), 149 deletions(-) diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 0dd5d797d196..334c24ef52fd 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -44,8 +44,8 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi create: () => Effect.void, getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), - revoke: () => Effect.succeed(false), - revokeAllExcept: () => Effect.succeed([]), + revoke: () => Effect.fail(repositoryFailure), + revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, }); @@ -104,11 +104,29 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessionError = yield* Effect.flip(sessions.verify(issued.token)); const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId)); + const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId)); expect(sessionError._tag).toBe("SessionCredentialVerificationError"); expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); expect(sessionError.cause).toBe(repositoryFailure); expect(websocketError.cause).toBe(repositoryFailure); + if (sessionError._tag === "SessionCredentialVerificationError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + } + if (websocketError._tag === "WebSocketTokenVerificationError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + } + expect(revokeError).toMatchObject({ + _tag: "SessionRevocationError", + sessionId: issued.sessionId, + cause: repositoryFailure, + }); + expect(revokeOthersError).toMatchObject({ + _tag: "OtherSessionsRevocationError", + currentSessionId: issued.sessionId, + cause: repositoryFailure, + }); }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), ); it.effect("verifies session tokens against the Effect clock", () => @@ -145,7 +163,52 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { yield* TestClock.adjust(Duration.seconds(2)); const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - expect(error.message).toContain("expired"); + expect(error._tag).toBe("WebSocketSessionExpiredError"); + if (error._tag === "WebSocketSessionExpiredError") { + expect(error.sessionId).toBe(issued.sessionId); + expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(error.observedAt.epochMilliseconds).toBeGreaterThan( + error.expiresAt.epochMilliseconds, + ); + } + }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), + ); + + it.effect("includes expiry context when session and websocket tokens expire", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue({ + method: "bearer-access-token", + subject: "short-lived-token", + ttl: Duration.seconds(1), + }); + const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, { + ttl: Duration.seconds(1), + }); + + yield* TestClock.adjust(Duration.seconds(2)); + + const sessionError = yield* Effect.flip(sessions.verify(issued.token)); + const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + + expect(sessionError._tag).toBe("SessionTokenExpiredError"); + if (sessionError._tag === "SessionTokenExpiredError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan( + sessionError.expiresAt.epochMilliseconds, + ); + } + expect(websocketError._tag).toBe("WebSocketTokenExpiredError"); + if (websocketError._tag === "WebSocketTokenExpiredError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + expect(websocketError.expiresAt.epochMilliseconds).toBe( + websocket.expiresAt.epochMilliseconds, + ); + expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan( + websocketError.expiresAt.epochMilliseconds, + ); + } }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); @@ -173,12 +236,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { ipAddress: "192.168.1.88", }, }); + const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId); yield* sessions.markConnected(client.sessionId); const beforeRevoke = yield* sessions.listActive(); const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId); const afterRevoke = yield* sessions.listActive(); const revokedClient = yield* Effect.flip(sessions.verify(client.token)); + const revokedClientWebSocket = yield* Effect.flip( + sessions.verifyWebSocketToken(clientWebSocket.token), + ); expect(beforeRevoke).toHaveLength(2); expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( @@ -194,7 +261,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(revokedCount).toBe(1); expect(afterRevoke).toHaveLength(1); expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId); - expect(revokedClient.message).toContain("revoked"); + expect(revokedClient._tag).toBe("SessionTokenRevokedError"); + if (revokedClient._tag === "SessionTokenRevokedError") { + expect(revokedClient.sessionId).toBe(client.sessionId); + expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } + expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError"); + if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") { + expect(revokedClientWebSocket.sessionId).toBe(client.sessionId); + expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } }).pipe(Effect.provide(makeSessionStoreLayer())), ); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 18008a7d0a17..12ecb7dba4d8 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -7,7 +7,6 @@ import { type AuthEnvironmentScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( "SessionTokenExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Session token expired."; @@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownSessionTokenError", - {}, + { + sessionId: AuthSessionId, + }, ) { override get message(): string { return "Unknown session token."; @@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( "SessionTokenRevokedError", - {}, + { + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Session token revoked."; @@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( "InvalidSessionExpirationClaimError", - {}, + { + sessionId: AuthSessionId, + expirationClaim: Schema.Number, + }, ) { override get message(): string { return "Invalid `exp` claim"; @@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( "WebSocketTokenExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket token expired."; @@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownWebSocketSessionError", - {}, + { + sessionId: AuthSessionId, + }, ) { override get message(): string { return "Unknown websocket session."; @@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( "WebSocketSessionExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket session expired."; @@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( "WebSocketSessionRevokedError", - {}, + { + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket session revoked."; @@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = { export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( "SessionClaimsEncodingError", { + sessionId: AuthSessionId, operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), ...sessionCredentialInternalErrorContext, }, @@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( "SessionCredentialIssueError", { + sessionId: Schema.optional(AuthSessionId), ...sessionCredentialInternalErrorContext, }, ) { @@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass()( "SessionCredentialVerificationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass< export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( "WebSocketTokenIssueError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( "WebSocketTokenVerificationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass()( "SessionRevocationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass()( "OtherSessionsRevocationError", { + currentSessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -539,7 +570,11 @@ export const make = Effect.gen(function* () { const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( function* (input) { - const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); + const sessionId = AuthSessionId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), + ), + ); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), @@ -559,27 +594,37 @@ export const make = Effect.gen(function* () { const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( - (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), + (cause) => + new SessionCredentialIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_session_claims", + cause, + }), + }), ), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); - yield* authSessions.create({ - sessionId, - subject: claims.sub, - scopes: claims.scopes, - method: claims.method, - client: { - label: client.label ?? null, - ipAddress: client.ipAddress ?? null, - userAgent: client.userAgent ?? null, - deviceType: client.deviceType, - os: client.os ?? null, - browser: client.browser ?? null, - }, - issuedAt, - expiresAt, - }); + yield* authSessions + .create({ + sessionId, + subject: claims.sub, + scopes: claims.scopes, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + }) + .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); yield* emitUpsert( toAuthClientSession({ sessionId, @@ -604,7 +649,6 @@ export const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), ); const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( @@ -623,22 +667,37 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), ); - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new SessionTokenExpiredError({}); + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new SessionTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, + }); } - const row = yield* authSessions.getById({ sessionId: claims.sid }); + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }), + ), + ); if (Option.isNone(row)) { - return yield* new UnknownSessionTokenError({}); + return yield* new UnknownSessionTokenError({ sessionId: claims.sid }); } if (row.value.revokedAt !== null) { - return yield* new SessionTokenRevokedError({}); - } - - const expiresAt = DateTime.make(claims.exp); - if (Option.isNone(expiresAt)) { - return yield* new InvalidSessionExpirationClaimError({}); + return yield* new SessionTokenRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, + }); } return { @@ -652,95 +711,111 @@ export const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies VerifiedSession; }, - Effect.mapError((cause) => - isSessionCredentialInvalidError(cause) - ? cause - : new SessionCredentialVerificationError({ cause }), - ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", - )( - function* (sessionId, input) { - const issuedAt = yield* DateTime.now; - const expiresAt = DateTime.add(issuedAt, { - milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), - }); - const claims: WebSocketClaims = { - v: 1, - kind: "websocket", - sid: sessionId, - iat: issuedAt.epochMilliseconds, - exp: expiresAt.epochMilliseconds, - }; - const encodedPayload = yield* encodeWsClaims(claims).pipe( - Effect.map(base64UrlEncode), - Effect.mapError( - (cause) => - new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), - ), - ); - const signature = signPayload(encodedPayload, signingSecret); - return { - token: `${encodedPayload}.${signature}`, - expiresAt, - }; - }, - Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), - ); + )(function* (sessionId, input) { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), + }); + const claims: WebSocketClaims = { + v: 1, + kind: "websocket", + sid: sessionId, + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = yield* encodeWsClaims(claims).pipe( + Effect.map(base64UrlEncode), + Effect.mapError( + (cause) => + new WebSocketTokenIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_websocket_claims", + cause, + }), + }), + ), + ); + const signature = signPayload(encodedPayload, signingSecret); + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + }; + }); const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", - )( - function* (token) { - const [encodedPayload, signature] = token.split("."); - if (!encodedPayload || !signature) { - return yield* new MalformedWebSocketTokenError({}); - } - - const expectedSignature = signPayload(encodedPayload, signingSecret); - if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new InvalidWebSocketTokenSignatureError({}); - } + )(function* (token) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new MalformedWebSocketTokenError({}); + } - const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), - ); + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new InvalidWebSocketTokenSignatureError({}); + } - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new WebSocketTokenExpiredError({}); - } + const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), + ); - const row = yield* authSessions.getById({ sessionId: claims.sid }); - if (Option.isNone(row)) { - return yield* new UnknownWebSocketSessionError({}); - } - if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new WebSocketSessionExpiredError({}); - } - if (row.value.revokedAt !== null) { - return yield* new WebSocketSessionRevokedError({}); - } + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new WebSocketTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, + }); + } - return { - sessionId: row.value.sessionId, - token, - method: row.value.method, - client: toClientMetadata(row.value.client), + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( + Effect.mapError( + (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }), + ), + ); + if (Option.isNone(row)) { + return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); + } + if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { + return yield* new WebSocketSessionExpiredError({ + sessionId: claims.sid, expiresAt: row.value.expiresAt, - subject: row.value.subject, - scopes: row.value.scopes, - } satisfies VerifiedSession; - }, - Effect.mapError((cause) => - isSessionCredentialInvalidError(cause) - ? cause - : new WebSocketTokenVerificationError({ cause }), - ), - ); + observedAt, + }); + } + if (row.value.revokedAt !== null) { + return yield* new WebSocketSessionRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, + }); + } + + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + scopes: row.value.scopes, + } satisfies VerifiedSession; + }); const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( function* () { @@ -768,10 +843,12 @@ export const make = Effect.gen(function* () { const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( function* (sessionId) { const revokedAt = yield* DateTime.now; - const revoked = yield* authSessions.revoke({ - sessionId, - revokedAt, - }); + const revoked = yield* authSessions + .revoke({ + sessionId, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); if (revoked) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -782,39 +859,41 @@ export const make = Effect.gen(function* () { } return revoked; }, - Effect.mapError((cause) => new SessionRevocationError({ cause })), ); const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", - )( - function* (sessionId) { - const revokedAt = yield* DateTime.now; - const revokedSessionIds = yield* authSessions.revokeAllExcept({ + )(function* (sessionId) { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions + .revokeAllExcept({ currentSessionId: sessionId, revokedAt, + }) + .pipe( + Effect.mapError( + (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), + ), + ); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; }); - if (revokedSessionIds.length > 0) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - for (const revokedSessionId of revokedSessionIds) { - next.delete(revokedSessionId); - } - return next; - }); - yield* Effect.forEach( - revokedSessionIds, - (revokedSessionId) => emitRemoved(revokedSessionId), - { - concurrency: "unbounded", - discard: true, - }, - ); - } - return revokedSessionIds.length; - }, - Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), - ); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); + } + return revokedSessionIds.length; + }); return SessionStore.of({ cookieName, From f0af75a506c53ccca0e818975271341b09734c7f Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Wed, 24 Jun 2026 12:22:03 +0530 Subject: [PATCH 78/80] Fix preview CI checks --- apps/web/src/components/chat/ChatComposer.tsx | 114 +++++++++--------- apps/web/src/rpc/requestLatencyState.ts | 5 + 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 933d923b44a6..826739a7a63f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2313,64 +2313,64 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ), ) .map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
))} )} diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 950e907e8650..1d8663edcfdd 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; @@ -36,6 +37,10 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(tag: string): boolean { + if (tag === WS_METHODS.previewAutomationConnect) { + return false; + } + // Skip subscribe RPCs (they are long-lived streams and the ack arrives much // later than the user-visible payload). Match `subscribe` at the start of // the tag or after a path-segment delimiter so `thread/unsubscribe` and From d6ca282c185534ff1d441959912c235dc52aaf8d Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Wed, 24 Jun 2026 12:30:27 +0530 Subject: [PATCH 79/80] Fix provider diagnostics checks --- apps/server/src/ampServerManager.ts | 31 ++--- apps/server/src/checkpointing/Utils.ts | 5 +- apps/server/src/commandPath.ts | 16 +-- .../server/src/geminiCliServerManager.test.ts | 5 +- apps/server/src/geminiCliServerManager.ts | 63 +++++----- apps/server/src/kilo/eventHandlers.test.ts | 1 + apps/server/src/kilo/eventHandlers.ts | 4 +- apps/server/src/kilo/serverLifecycle.ts | 21 ++-- apps/server/src/kilo/utils.ts | 7 +- apps/server/src/kiloServerManager.test.ts | 1 + apps/server/src/kiloServerManager.ts | 8 +- apps/server/src/logger.ts | 5 +- .../Layers/OrchestrationEventStore.test.ts | 1 + .../src/provider/Layers/AmpAdapter.test.ts | 27 ++-- .../server/src/provider/Layers/AmpProvider.ts | 2 + .../provider/Layers/CopilotAdapter.test.ts | 21 ++-- .../src/provider/Layers/CopilotAdapter.ts | 11 +- .../provider/Layers/CopilotProvider.test.ts | 12 +- .../src/provider/Layers/CopilotProvider.ts | 2 + .../src/provider/Layers/DroidAdapter.test.ts | 117 +++++++++--------- .../src/provider/Layers/DroidAdapter.ts | 8 +- .../src/provider/Layers/DroidProvider.ts | 5 +- .../provider/Layers/GeminiCliAdapter.test.ts | 21 ++-- .../provider/Layers/GeminiCliProvider.test.ts | 6 +- .../src/provider/Layers/GeminiCliProvider.ts | 2 + .../src/provider/Layers/KiloAdapter.test.ts | 21 ++-- .../src/provider/Layers/KiloProvider.test.ts | 20 +-- .../src/provider/Layers/KiloProvider.ts | 2 + .../src/provider/Layers/copilotCliPath.ts | 45 ++++--- .../src/provider/droid/DroidRuntimeEvents.ts | 4 +- apps/server/src/vcs/GitVcsDriverCore.ts | 5 +- apps/server/vite.config.ts | 4 +- scripts/lib/macos-icon-composer.ts | 33 ++--- scripts/sync-upstream-pr-tracks.mjs | 14 +-- 34 files changed, 299 insertions(+), 251 deletions(-) diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 7acd8b77d4e4..895f96185b3b 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -1,7 +1,8 @@ -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import readline from "node:readline"; +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Provider process manager owns child process lifecycle and timestamped runtime events. +import * as NodeCrypto from "node:crypto"; +import * as NodeEvents from "node:events"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeReadline from "node:readline"; import { ApprovalRequestId, @@ -44,8 +45,8 @@ type AmpProviderOptions = { interface AmpSession { readonly threadId: ThreadId; - readonly process: ChildProcessWithoutNullStreams; - readonly rl: readline.Interface; + readonly process: NodeChildProcess.ChildProcessWithoutNullStreams; + readonly rl: NodeReadline.Interface; model: string | undefined; cwd: string; runtimeMode: string; @@ -144,7 +145,7 @@ interface AmpJsonlMessage { // ── Manager ───────────────────────────────────────────────────────── -export class AmpServerManager extends EventEmitter<{ +export class AmpServerManager extends NodeEvents.EventEmitter<{ event: [ProviderRuntimeEvent]; }> { private readonly sessions = new Map(); @@ -209,13 +210,13 @@ export class AmpServerManager extends EventEmitter<{ args.push("--dangerously-allow-all"); } - const child = spawn(binaryPath, args, { + const child = NodeChildProcess.spawn(binaryPath, args, { cwd, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env }, }); - const rl = readline.createInterface({ input: child.stdout }); + const rl = NodeReadline.createInterface({ input: child.stdout }); const session: AmpSession = { threadId, @@ -343,7 +344,7 @@ export class AmpServerManager extends EventEmitter<{ throw new Error("Attachments are not supported by AMP"); } - const turnId = TurnId.make(randomUUID()); + const turnId = TurnId.make(NodeCrypto.randomUUID()); const prompt = input.input ?? ""; // Write a JSONL user message to stdin for the persistent AMP process. @@ -611,7 +612,7 @@ export class AmpServerManager extends EventEmitter<{ switch (block.type) { case "text": { if (!session.activeAssistantItemId) { - session.activeAssistantItemId = RuntimeItemId.make(randomUUID()); + session.activeAssistantItemId = RuntimeItemId.make(NodeCrypto.randomUUID()); } this.emitEvent( threadId, @@ -630,7 +631,7 @@ export class AmpServerManager extends EventEmitter<{ case "thinking": { if (!session.activeAssistantItemId) { - session.activeAssistantItemId = RuntimeItemId.make(randomUUID()); + session.activeAssistantItemId = RuntimeItemId.make(NodeCrypto.randomUUID()); } this.emitEvent( threadId, @@ -687,7 +688,7 @@ export class AmpServerManager extends EventEmitter<{ const existing = session.subagentTasks.get(parentToolUseId); if (!existing) { // First occurrence — emit task.started. - const taskId = RuntimeTaskId.make(randomUUID()); + const taskId = RuntimeTaskId.make(NodeCrypto.randomUUID()); session.subagentTasks.set(parentToolUseId, taskId); this.emitEvent(threadId, session.activeTurnId, { type: "task.started", @@ -831,7 +832,7 @@ export class AmpServerManager extends EventEmitter<{ ): void { const event = { type: partial.type, - eventId: EventId.make(randomUUID()), + eventId: EventId.make(NodeCrypto.randomUUID()), provider: PROVIDER, createdAt: new Date().toISOString(), threadId, @@ -839,7 +840,7 @@ export class AmpServerManager extends EventEmitter<{ ...(itemId ? { itemId } : partial.type === "content.delta" - ? { itemId: RuntimeItemId.make(randomUUID()) } + ? { itemId: RuntimeItemId.make(NodeCrypto.randomUUID()) } : {}), payload: partial.payload, } as unknown as ProviderRuntimeEvent; diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index c4802efdef71..cc0564ceee08 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +// @effect-diagnostics nodeBuiltinImport:off - Checkpoint utility probes filesystem paths directly. +import * as NodeFS from "node:fs"; import * as Encoding from "effect/Encoding"; import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; @@ -43,5 +44,5 @@ export function resolveExistingThreadWorkspaceCwd(input: { if (!resolvedCwd) { return undefined; } - return existsSync(resolvedCwd) ? resolvedCwd : undefined; + return NodeFS.existsSync(resolvedCwd) ? resolvedCwd : undefined; } diff --git a/apps/server/src/commandPath.ts b/apps/server/src/commandPath.ts index a45c4cda4247..6c3e1736f0a4 100644 --- a/apps/server/src/commandPath.ts +++ b/apps/server/src/commandPath.ts @@ -1,5 +1,6 @@ -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +// @effect-diagnostics nodeBuiltinImport:off - Pure command path resolver intentionally mirrors host PATH lookup. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; interface CommandPathOptions { readonly platform?: NodeJS.Platform; @@ -41,7 +42,7 @@ function resolveCommandCandidates( windowsPathExtensions: ReadonlyArray, ): ReadonlyArray { if (platform !== "win32") return [command]; - const extension = extname(command); + const extension = NodePath.extname(command); const normalizedExtension = extension.toUpperCase(); if (extension.length > 0) { @@ -80,16 +81,16 @@ function isExecutableFile( windowsPathExtensions: ReadonlyArray, ): boolean { try { - const stat = statSync(filePath); + const stat = NodeFS.statSync(filePath); if (!stat.isFile()) return false; if (platform === "win32") { - const extension = extname(filePath); + const extension = NodePath.extname(filePath); if (extension.length === 0) return false; return new Set([...DEFAULT_WINDOWS_PATH_EXTENSIONS, ...windowsPathExtensions]).has( extension.toUpperCase(), ); } - accessSync(filePath, constants.X_OK); + NodeFS.accessSync(filePath, NodeFS.constants.X_OK); return true; } catch { return false; @@ -100,6 +101,7 @@ export function resolveCommandPath( command: string, options: CommandPathOptions = {}, ): string | undefined { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure utility keeps an optional injectable platform for tests and non-Effect call sites. const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; @@ -121,7 +123,7 @@ export function resolveCommandPath( for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { - const resolvedPath = join(pathEntry, candidate); + const resolvedPath = NodePath.join(pathEntry, candidate); if (isExecutableFile(resolvedPath, platform, windowsPathExtensions)) { return resolvedPath; } diff --git a/apps/server/src/geminiCliServerManager.test.ts b/apps/server/src/geminiCliServerManager.test.ts index 5757ecc090ca..c9a916ac2db9 100644 --- a/apps/server/src/geminiCliServerManager.test.ts +++ b/apps/server/src/geminiCliServerManager.test.ts @@ -1,5 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Tests exercise Node path-like inputs and timestamped provider events. import { describe, expect, it, vi, beforeEach } from "vite-plus/test"; -import type { PathLike } from "node:fs"; +import * as NodeFS from "node:fs"; import { ProviderDriverKind, ProviderInstanceId, @@ -65,7 +66,7 @@ describe("GeminiCliServerManager", () => { } return undefined; }, - existsSync: (path: PathLike) => + existsSync: (path: NodeFS.PathLike) => String(path).replace(/\\/g, "/") === "C:/Users/user/AppData/Roaming/npm/node_modules/@google/gemini-cli/dist/index.js", }, diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index 5ab113f56d19..08fd6e6f8abd 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -1,14 +1,10 @@ -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; -import { existsSync } from "node:fs"; -import { extname, win32 as win32Path } from "node:path"; -import { - spawn, - spawnSync, - type ChildProcess, - type ChildProcessWithoutNullStreams, -} from "node:child_process"; -import readline from "node:readline"; +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Provider process manager owns child process lifecycle and timestamped runtime events. +import * as NodeCrypto from "node:crypto"; +import * as NodeEvents from "node:events"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeReadline from "node:readline"; import { ApprovalRequestId, @@ -137,7 +133,7 @@ interface GeminiCliSession { /** Gemini-native session ID for --resume. */ geminiSessionId: string | undefined; activeTurnId: TurnId | undefined; - activeProcess: ChildProcess | undefined; + activeProcess: NodeChildProcess.ChildProcess | undefined; interruptedTurnId: TurnId | undefined; /** Stable itemId for the current turn's assistant message (reused across content.delta events). */ activeAssistantItemId: RuntimeItemId | undefined; @@ -179,19 +175,19 @@ interface GeminiSpawnPlan { interface GeminiSpawnPlanDependencies { readonly resolveCommandPath?: typeof resolveCommandPath; - readonly existsSync?: typeof existsSync; + readonly existsSync?: typeof NodeFS.existsSync; } function resolveGeminiShimEntryPoint( binaryPath: string, - fileExists: typeof existsSync = existsSync, + fileExists: typeof NodeFS.existsSync = NodeFS.existsSync, ): string | undefined { - if (![".cmd", ".bat"].includes(extname(binaryPath).toLowerCase())) { + if (![".cmd", ".bat"].includes(NodePath.extname(binaryPath).toLowerCase())) { return undefined; } - const shimDirectory = win32Path.dirname(binaryPath); - const shimEntryPoint = win32Path.join( + const shimDirectory = NodePath.win32.dirname(binaryPath); + const shimEntryPoint = NodePath.win32.join( shimDirectory, "node_modules", "@google", @@ -205,6 +201,7 @@ function resolveGeminiShimEntryPoint( function resolveNodeCommand( env: NodeJS.ProcessEnv, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure helper keeps platform injectable for tests and non-Effect callers. platform: NodeJS.Platform = process.platform, commandPathResolver: typeof resolveCommandPath = resolveCommandPath, ): string { @@ -221,11 +218,12 @@ export function resolveGeminiSpawnPlan( readonly cwd: string; readonly env: NodeJS.ProcessEnv; }, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure spawn planner keeps platform injectable for tests and class callers. platform: NodeJS.Platform = process.platform, dependencies: GeminiSpawnPlanDependencies = {}, ): GeminiSpawnPlan { const commandPathResolver = dependencies.resolveCommandPath ?? resolveCommandPath; - const fileExists = dependencies.existsSync ?? existsSync; + const fileExists = dependencies.existsSync ?? NodeFS.existsSync; const options = buildGeminiSpawnOptions({ cwd: input.cwd, env: input.env, @@ -245,7 +243,7 @@ export function resolveGeminiSpawnPlan( env: input.env, }) ?? input.binaryPath; - if (extname(resolvedBinaryPath).toLowerCase() === ".js") { + if (NodePath.extname(resolvedBinaryPath).toLowerCase() === ".js") { return { command: resolveNodeCommand(input.env, platform, commandPathResolver), args: [resolvedBinaryPath, ...input.args], @@ -269,10 +267,17 @@ export function resolveGeminiSpawnPlan( }; } -function killGeminiChildProcess(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void { - if (process.platform === "win32" && child.pid !== undefined) { +function killGeminiChildProcess( + child: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals = "SIGTERM", + // oxlint-disable-next-line t3code/no-global-process-runtime -- Manager is a non-Effect process owner; tests can pass a platform explicitly. + platform: NodeJS.Platform = process.platform, +): void { + if (platform === "win32" && child.pid !== undefined) { try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + NodeChildProcess.spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); return; } catch { // Fall back to direct kill when taskkill is unavailable. @@ -324,7 +329,7 @@ function resolveApprovalMode(runtimeMode: string): string { } } -export class GeminiCliServerManager extends EventEmitter<{ +export class GeminiCliServerManager extends NodeEvents.EventEmitter<{ event: [ProviderRuntimeEvent]; }> { private readonly sessions = new Map(); @@ -435,7 +440,7 @@ export class GeminiCliServerManager extends EventEmitter<{ throw new Error("Gemini CLI does not support attachments"); } - const turnId = TurnId.make(randomUUID()); + const turnId = TurnId.make(NodeCrypto.randomUUID()); session.activeTurnId = turnId; session.status = "running"; session.updatedAt = new Date().toISOString(); @@ -474,7 +479,7 @@ export class GeminiCliServerManager extends EventEmitter<{ env: { ...process.env }, }); - const child: ChildProcessWithoutNullStreams = spawn( + const child: NodeChildProcess.ChildProcessWithoutNullStreams = NodeChildProcess.spawn( spawnPlan.command, [...spawnPlan.args], spawnPlan.options, @@ -491,7 +496,7 @@ export class GeminiCliServerManager extends EventEmitter<{ }); let stderrSummary = ""; - const rl = readline.createInterface({ input: child.stdout }); + const rl = NodeReadline.createInterface({ input: child.stdout }); rl.on("line", (line) => { this.handleJsonLine(input.threadId, turnId, line); @@ -695,7 +700,7 @@ export class GeminiCliServerManager extends EventEmitter<{ if (event.role === "assistant" && event.content) { // Reuse a stable itemId so all deltas aggregate into one assistant message. if (!session.activeAssistantItemId) { - session.activeAssistantItemId = RuntimeItemId.make(randomUUID()); + session.activeAssistantItemId = RuntimeItemId.make(NodeCrypto.randomUUID()); } this.emitEvent(threadId, turnId, { type: "content.delta", @@ -724,7 +729,7 @@ export class GeminiCliServerManager extends EventEmitter<{ session.activeAssistantItemId = undefined; } - const itemId = RuntimeItemId.make(randomUUID()); + const itemId = RuntimeItemId.make(NodeCrypto.randomUUID()); const toolTitle = summarizeToolCall(event.tool_name, event.parameters); const paramSummary = typeof event.parameters === "object" ? JSON.stringify(event.parameters) : undefined; @@ -862,7 +867,7 @@ export class GeminiCliServerManager extends EventEmitter<{ ): void { const event = { type: partial.type, - eventId: EventId.make(randomUUID()), + eventId: EventId.make(NodeCrypto.randomUUID()), provider: PROVIDER, createdAt: new Date().toISOString(), threadId, diff --git a/apps/server/src/kilo/eventHandlers.test.ts b/apps/server/src/kilo/eventHandlers.test.ts index 82496ea2e801..fba1f115647d 100644 --- a/apps/server/src/kilo/eventHandlers.test.ts +++ b/apps/server/src/kilo/eventHandlers.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off - Tests build timestamped Kilo server events. import { ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; diff --git a/apps/server/src/kilo/eventHandlers.ts b/apps/server/src/kilo/eventHandlers.ts index a529f30fcddc..2a75aeb69605 100644 --- a/apps/server/src/kilo/eventHandlers.ts +++ b/apps/server/src/kilo/eventHandlers.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { ApprovalRequestId, RuntimeItemId, RuntimeRequestId } from "@t3tools/contracts"; @@ -838,7 +838,7 @@ function handleCommandExecutedEvent( if (sessionID !== context.providerSessionId) { return; } - const itemId = RuntimeItemId.make(`cmd:${command}:${randomUUID()}`); + const itemId = RuntimeItemId.make(`cmd:${command}:${NodeCrypto.randomUUID()}`); const title = `Command: ${command}`; emitter.emitRuntimeEvent({ type: "item.started", diff --git a/apps/server/src/kilo/serverLifecycle.ts b/apps/server/src/kilo/serverLifecycle.ts index 1aacd5459d94..cd2b9e7708f6 100644 --- a/apps/server/src/kilo/serverLifecycle.ts +++ b/apps/server/src/kilo/serverLifecycle.ts @@ -1,4 +1,5 @@ -import { spawn } from "node:child_process"; +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalTimers:off - Kilo lifecycle helper owns raw process, HTTP readiness polling, and timeout boundaries. +import * as NodeChildProcess from "node:child_process"; import { DEFAULT_HOSTNAME, @@ -84,14 +85,18 @@ async function spawnOrConnect(options?: KiloProviderOptions): Promise((resolve, reject) => { let output = ""; diff --git a/apps/server/src/kilo/utils.ts b/apps/server/src/kilo/utils.ts index ceb647ae3025..8e5a8066abf4 100644 --- a/apps/server/src/kilo/utils.ts +++ b/apps/server/src/kilo/utils.ts @@ -1,4 +1,5 @@ -import { randomUUID } from "node:crypto"; +// @effect-diagnostics globalDate:off - Kilo protocol payloads require ISO timestamps. +import * as NodeCrypto from "node:crypto"; import { EventId, @@ -36,7 +37,7 @@ export function asString(value: unknown): string | undefined { } export function eventId(prefix: string): EventId { - return EventId.make(`${prefix}:${randomUUID()}`); + return EventId.make(`${prefix}:${NodeCrypto.randomUUID()}`); } export function nowIso(): string { @@ -44,7 +45,7 @@ export function nowIso(): string { } export function createTurnId(): TurnId { - return TurnId.make(`turn:${randomUUID()}`); + return TurnId.make(`turn:${NodeCrypto.randomUUID()}`); } export function textPart(text: string) { diff --git a/apps/server/src/kiloServerManager.test.ts b/apps/server/src/kiloServerManager.test.ts index 4f54348d956b..e8f55ef78598 100644 --- a/apps/server/src/kiloServerManager.test.ts +++ b/apps/server/src/kiloServerManager.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalTimers:off - Tests build timestamped Kilo events and wait for async lifecycle behavior. import { ApprovalRequestId, ThreadId, TurnId, type ProviderRuntimeEvent } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index d1537a3ad3c5..d9d7ceed9b95 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -1,5 +1,5 @@ -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; +import * as NodeCrypto from "node:crypto"; +import * as NodeEvents from "node:events"; import { ApprovalRequestId, @@ -50,7 +50,7 @@ import { createClient, ensureServer } from "./kilo/serverLifecycle.ts"; export { type KiloDiscoveredModel, type KiloModelDiscoveryOptions } from "./kilo/types.ts"; -export class KiloServerManager extends EventEmitter { +export class KiloServerManager extends NodeEvents.EventEmitter { private readonly sessions = new Map(); private serverPromise: Promise | undefined; private server: SharedServerState | undefined; @@ -436,7 +436,7 @@ export class KiloServerManager extends EventEmitter { const turns = (Array.isArray(messages) ? messages : []).map((entry) => { const info = asRecord(asRecord(entry)?.info); - const messageId = asString(info?.id) ?? randomUUID(); + const messageId = asString(info?.id) ?? NodeCrypto.randomUUID(); return { id: TurnId.make(messageId), items: [entry], diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts index b9d18569ccfd..2a561d2dccf7 100644 --- a/apps/server/src/logger.ts +++ b/apps/server/src/logger.ts @@ -1,4 +1,5 @@ -import util from "node:util"; +// @effect-diagnostics globalDate:off globalConsole:off - Minimal process logger intentionally writes timestamped console output. +import * as NodeUtil from "node:util"; type LogLevel = "info" | "warn" | "error" | "event"; @@ -51,7 +52,7 @@ function formatValue(value: unknown) { ) { return String(value); } - return util.inspect(value, { + return NodeUtil.inspect(value, { depth: 4, breakLength: Infinity, compact: true, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index b7fd7a230561..ed894603fb34 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDateInEffect:off preferSchemaOverJson:off - Persistence tests assert raw stored JSON and timestamp behavior. import { CommandId, EventId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/provider/Layers/AmpAdapter.test.ts b/apps/server/src/provider/Layers/AmpAdapter.test.ts index dcd5ba6583ea..f0230f05475d 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { ApprovalRequestId, @@ -130,8 +131,8 @@ it.effect("AmpAdapter delegates session startup to the manager", () => runtimeMode: "full-access", }); - assert.equal(session.provider, "amp"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + NodeAssert.equal(session.provider, "amp"); + NodeAssert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }).pipe(Effect.scoped), ); @@ -147,11 +148,11 @@ it.effect("AmpAdapter rejects startSession when provider is disabled", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.scoped), ); @@ -168,11 +169,11 @@ it.effect("AmpAdapter rejects attachments until AMP attachment wiring exists", ( }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.scoped), ); @@ -185,11 +186,11 @@ it.effect("AmpAdapter rejects rollbackThread with non-positive numTurns", () => .rollbackThread(asThreadId("thread-rollback"), 0) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.scoped), ); @@ -200,7 +201,7 @@ it.effect("AmpAdapter forwards interruptTurn calls to the manager", () => yield* adapter.interruptTurn(asThreadId("thread-interrupt")); - assert.equal(manager.interruptTurnImpl.mock.calls.length, 1); + NodeAssert.equal(manager.interruptTurnImpl.mock.calls.length, 1); }).pipe(Effect.scoped), ); @@ -231,14 +232,14 @@ it.effect("AmpAdapter forwards manager runtime events through the adapter stream // resolves immediately without a race condition. const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); + NodeAssert.equal(received._tag, "Some"); if (received._tag !== "Some") { return; } - assert.equal(received.value.type, "content.delta"); + NodeAssert.equal(received.value.type, "content.delta"); if (received.value.type !== "content.delta") { return; } - assert.equal(received.value.payload.delta, "hello"); + NodeAssert.equal(received.value.payload.delta, "hello"); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Layers/AmpProvider.ts b/apps/server/src/provider/Layers/AmpProvider.ts index 11a78e626661..31a44976e4aa 100644 --- a/apps/server/src/provider/Layers/AmpProvider.ts +++ b/apps/server/src/provider/Layers/AmpProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * AmpProvider — snapshot probe for the Amp CLI provider. * @@ -80,6 +81,7 @@ const runAmpCommand = Effect.fn("runAmpCommand")(function* ( const binaryPath = defaultBinaryPath(ampSettings); const command = ChildProcess.make(binaryPath, [...args], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index e432217d79dc..ae837d00ee77 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { type SessionEvent } from "@github/copilot-sdk"; @@ -163,12 +164,12 @@ modeLayer("CopilotAdapterLive interaction mode", (it) => { attachments: [], }); - assert.deepStrictEqual(modeSession.modeSetImpl.mock.calls, [ + NodeAssert.deepStrictEqual(modeSession.modeSetImpl.mock.calls, [ [{ mode: "plan" }], [{ mode: "interactive" }], ]); - assert.equal(modeSession.sendImpl.mock.calls[0]?.[0]?.mode, "immediate"); - assert.equal(modeSession.sendImpl.mock.calls[1]?.[0]?.mode, "immediate"); + NodeAssert.equal(modeSession.sendImpl.mock.calls[0]?.[0]?.mode, "immediate"); + NodeAssert.equal(modeSession.sendImpl.mock.calls[1]?.[0]?.mode, "immediate"); }), ); }); @@ -229,16 +230,16 @@ planLayer("CopilotAdapterLive proposed plan events", (it) => { } satisfies SessionEvent); const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.equal(events[0]?.type, "turn.plan.updated"); + NodeAssert.equal(events[0]?.type, "turn.plan.updated"); if (events[0]?.type === "turn.plan.updated") { - assert.equal(events[0].turnId, turn.turnId); - assert.equal(events[0].payload.explanation, "Plan updated"); + NodeAssert.equal(events[0].turnId, turn.turnId); + NodeAssert.equal(events[0].payload.explanation, "Plan updated"); } - assert.equal(events[1]?.type, "turn.proposed.completed"); + NodeAssert.equal(events[1]?.type, "turn.proposed.completed"); if (events[1]?.type === "turn.proposed.completed") { - assert.equal(events[1].turnId, turn.turnId); - assert.equal(events[1].payload.planMarkdown, "# Ship it\n\n- first\n- second"); + NodeAssert.equal(events[1].turnId, turn.turnId); + NodeAssert.equal(events[1].payload.planMarkdown, "# Ship it\n\n- first\n- second"); } }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 26f64a14331e..b20c2d31ce1e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Adapter emits provider protocol timestamps. /** * CopilotAdapter — `ProviderAdapterShape` for the GitHub Copilot SDK runtime. * @@ -24,7 +25,7 @@ * etc.) is owned per `ActiveCopilotSession`, which itself lives inside * the per-driver-instance `sessions` map. */ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { EventId, @@ -184,7 +185,7 @@ interface CopilotClientHandle { } function makeEventId(prefix: string) { - return EventId.make(`${prefix}-${randomUUID()}`); + return EventId.make(`${prefix}-${NodeCrypto.randomUUID()}`); } function toTurnId(value: string | undefined): TurnId | undefined { @@ -1055,7 +1056,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( getRuntimeMode() === "full-access" ? Promise.resolve({ kind: "approved" }) : new Promise((resolve) => { - const requestId = `copilot-approval-${randomUUID()}`; + const requestId = `copilot-approval-${NodeCrypto.randomUUID()}`; const turnId = getCurrentTurnId(); pendingApprovalResolvers.set(requestId, { requestType: requestTypeFromPermissionRequest(request), @@ -1080,7 +1081,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const onUserInputRequest = (request: CopilotUserInputRequest) => new Promise((resolve) => { - const requestId = `copilot-user-input-${randomUUID()}`; + const requestId = `copilot-user-input-${NodeCrypto.randomUUID()}`; const turnId = getCurrentTurnId(); pendingUserInputResolvers.set(requestId, { request, @@ -1530,7 +1531,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const interactionMode = input.interactionMode ?? record.interactionMode ?? "default"; yield* syncInteractionMode(record, interactionMode); - const turnId = TurnId.make(`copilot-turn-${randomUUID()}`); + const turnId = TurnId.make(`copilot-turn-${NodeCrypto.randomUUID()}`); record.pendingTurnIds.push(turnId); record.currentTurnId = turnId; record.currentProviderTurnId = undefined; diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 98cdad3ad1aa..848344a6111b 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as Schema from "effect/Schema"; import { describe, it } from "vite-plus/test"; @@ -22,15 +22,15 @@ describe("CopilotProvider reasoning effort", () => { const draft = makePendingCopilotProvider(settings); const model = draft.models[0]; - assert.ok(model, "expected at least one copilot model"); + NodeAssert.ok(model, "expected at least one copilot model"); const descriptors = model.capabilities?.optionDescriptors ?? []; const effort = descriptors.find((descriptor) => descriptor.id === "reasoningEffort"); if (!effort || effort.type !== "select") { - assert.fail("reasoningEffort select descriptor must be present"); + NodeAssert.fail("reasoningEffort select descriptor must be present"); } - assert.deepEqual( + NodeAssert.deepEqual( effort.options.map((option) => option.id), ["low", "medium", "high", "xhigh"], ); @@ -39,8 +39,8 @@ describe("CopilotProvider reasoning effort", () => { // selector dispatches nothing and the adapter's per-model validation is // skipped — preserving the prior "no effort" behavior on models that do // not advertise the picked effort in supportedReasoningEfforts. - assert.equal(effort.currentValue, undefined); - assert.ok( + NodeAssert.equal(effort.currentValue, undefined); + NodeAssert.ok( effort.options.every((option) => option.isDefault !== true), "no reasoningEffort option may be marked isDefault (opt-in)", ); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index b3dde6cab094..b62a5fd32a39 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * CopilotProvider — snapshot probe for the GitHub Copilot driver. * @@ -90,6 +91,7 @@ const runCopilotVersionCommand = Effect.fn("runCopilotVersionCommand")(function* ) { const command = ChildProcess.make(binaryPath, ["--version"], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index 177f7a29dc46..e8716a4f823b 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off - Tests build timestamped Droid events. +import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { @@ -170,11 +171,11 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => yield* adapter.sendTurn({ threadId, input: "hello" }); const events = yield* joinIterableFiber(eventsFiber); - assert.equal(createOptions?.modelId, "claude-sonnet"); - assert.equal(createOptions?.autonomyLevel, AutonomyLevel.High); - assert.equal(createOptions?.interactionMode, DroidInteractionMode.Auto); - assert.equal(createOptions?.reasoningEffort, ReasoningEffort.High); - assert.deepEqual( + NodeAssert.equal(createOptions?.modelId, "claude-sonnet"); + NodeAssert.equal(createOptions?.autonomyLevel, AutonomyLevel.High); + NodeAssert.equal(createOptions?.interactionMode, DroidInteractionMode.Auto); + NodeAssert.equal(createOptions?.reasoningEffort, ReasoningEffort.High); + NodeAssert.deepEqual( events.map((event) => event.type), [ "session.started", @@ -202,11 +203,11 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => lastOutputTokens: 5, lastReasoningOutputTokens: 1, }; - assert.deepEqual( + NodeAssert.deepEqual( events.find((event) => event.type === "thread.token-usage.updated")?.payload, { usage: expectedUsage }, ); - assert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { + NodeAssert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { state: "completed", usage: expectedUsage, }); @@ -279,7 +280,7 @@ it.effect("keeps Droid token usage cumulative across turns", () => const usageEvents = events.filter((event) => event.type === "thread.token-usage.updated"); const completedTurns = events.filter((event) => event.type === "turn.completed"); - assert.deepEqual( + NodeAssert.deepEqual( usageEvents.map((event) => event.type === "thread.token-usage.updated" ? event.payload.usage : undefined, ), @@ -310,7 +311,7 @@ it.effect("keeps Droid token usage cumulative across turns", () => }, ], ); - assert.deepEqual( + NodeAssert.deepEqual( completedTurns.map((event) => event.type === "turn.completed" ? (event.payload as { usage?: { usedTokens?: number } }).usage?.usedTokens @@ -343,7 +344,7 @@ it.effect("maps Droid medium access to medium autonomy", () => runtimeMode: "medium-access", }); - assert.equal(createOptions?.autonomyLevel, AutonomyLevel.Medium); + NodeAssert.equal(createOptions?.autonomyLevel, AutonomyLevel.Medium); }), ).pipe(Effect.provide(testLayer)), ); @@ -378,8 +379,8 @@ it.effect("applies runtime autonomy when resuming Droid sessions and sending tur yield* adapter.sendTurn({ threadId, input: "hello" }); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.deepEqual(updateSettingsCalls, [ + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.deepEqual(updateSettingsCalls, [ { autonomyLevel: AutonomyLevel.Off }, { autonomyLevel: AutonomyLevel.Off }, ]); @@ -419,11 +420,11 @@ it.effect("closes an existing Droid session before replacing the same thread", ( runtimeMode: "full-access", }); - assert.deepEqual(closedSessionIds, ["droid-session-1"]); - assert.equal(secondSession.resumeCursor, "droid-session-2"); + NodeAssert.deepEqual(closedSessionIds, ["droid-session-1"]); + NodeAssert.equal(secondSession.resumeCursor, "droid-session-2"); const sessions = yield* adapter.listSessions(); - assert.equal(sessions.length, 1); - assert.equal(sessions[0]?.resumeCursor, "droid-session-2"); + NodeAssert.equal(sessions.length, 1); + NodeAssert.equal(sessions[0]?.resumeCursor, "droid-session-2"); }), ).pipe(Effect.provide(testLayer)), ); @@ -467,7 +468,7 @@ it.effect("uses final Droid create_message content when deltas are absent", () = const events = yield* joinIterableFiber(eventsFiber); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), [ { streamKind: "reasoning_text", delta: "final thought" }, @@ -475,10 +476,10 @@ it.effect("uses final Droid create_message content when deltas are absent", () = ], ); const completed = events.find((event) => event.type === "item.completed"); - assert.equal(completed?.type, "item.completed"); + NodeAssert.equal(completed?.type, "item.completed"); if (completed?.type === "item.completed") { - assert.equal(completed.payload.itemType, "assistant_message"); - assert.equal(completed.payload.detail, "final text"); + NodeAssert.equal(completed.payload.itemType, "assistant_message"); + NodeAssert.equal(completed.payload.detail, "final text"); } }), ).pipe(Effect.provide(testLayer)), @@ -532,16 +533,16 @@ it.effect("does not duplicate Droid final create_message text after streaming de const events = yield* joinIterableFiber(eventsFiber); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : undefined)), ["stre", "am"], ); const completed = events.find((event) => event.type === "item.completed"); - assert.equal(completed?.type, "item.completed"); + NodeAssert.equal(completed?.type, "item.completed"); if (completed?.type === "item.completed") { - assert.equal(completed.payload.detail, "stream"); + NodeAssert.equal(completed.payload.detail, "stream"); } - assert.equal( + NodeAssert.equal( events.filter( (event) => event.type === "item.completed" && event.payload.itemType === "assistant_message", @@ -586,9 +587,9 @@ it.effect("rejects concurrent Droid turns for the same thread", () => yield* adapter.sendTurn({ threadId, input: "first" }); const secondTurn = yield* adapter.sendTurn({ threadId, input: "second" }).pipe(Effect.exit); - assert.equal(secondTurn._tag, "Failure"); + NodeAssert.equal(secondTurn._tag, "Failure"); if (secondTurn._tag === "Failure") { - assert.match(String(secondTurn.cause), /already has an active turn/); + NodeAssert.match(String(secondTurn.cause), /already has an active turn/); } finishTurn?.(); @@ -653,7 +654,7 @@ it.effect("does not duplicate Droid final thinking content after streaming delta const events = yield* joinIterableFiber(eventsFiber); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), [ { streamKind: "reasoning_text", delta: "thi" }, @@ -690,8 +691,8 @@ it.effect("ignores Droid interrupt failures after aborting the active turn", () }); const exit = yield* adapter.interruptTurn(threadId).pipe(Effect.exit); - assert.equal(exit._tag, "Success"); - assert.equal(interruptAttempts, 1); + NodeAssert.equal(exit._tag, "Success"); + NodeAssert.equal(interruptAttempts, 1); }), ).pipe(Effect.provide(testLayer)), ); @@ -739,12 +740,12 @@ it.effect("passes custom model reasoning into Droid spec mode", () => }); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.deepEqual(enterSpecModeParams, { + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.deepEqual(enterSpecModeParams, { specModeModelId: "custom:Direct-GPT-5.5-xhigh-27", specModeReasoningEffort: ReasoningEffort.ExtraHigh, }); - assert.deepEqual(updateSettingsParams, { + NodeAssert.deepEqual(updateSettingsParams, { autonomyLevel: AutonomyLevel.High, modelId: "custom:Direct-GPT-5.5-xhigh-27", reasoningEffort: ReasoningEffort.ExtraHigh, @@ -807,17 +808,17 @@ it.effect("routes Droid permission requests through adapter approvals", () => }); yield* adapter.sendTurn({ threadId, input: "run lint" }); const opened = yield* Fiber.join(openedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(opened._tag, "Some"); + NodeAssert.equal(opened._tag, "Some"); const requestId = opened.value.requestId; - assert.ok(requestId); + NodeAssert.ok(requestId); yield* adapter.respondToRequest( threadId, ApprovalRequestId.make(requestId), "acceptForSession", ); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.equal(permissionResult, ToolConfirmationOutcome.ProceedAlways); + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.equal(permissionResult, ToolConfirmationOutcome.ProceedAlways); }), ).pipe(Effect.provide(testLayer)), ); @@ -901,32 +902,32 @@ it.effect("settles pending Droid permission and user-input waits when stopped", }); yield* adapter.sendTurn({ threadId, input: "run lint" }); const openedEvents = yield* joinIterableFiber(openedEventsFiber); - assert.deepEqual(openedEvents.map((event) => event.type).toSorted(), [ + NodeAssert.deepEqual(openedEvents.map((event) => event.type).toSorted(), [ "request.opened", "user-input.requested", ]); yield* adapter.stopSession(threadId); const resolvedEvents = yield* joinIterableFiber(resolvedEventsFiber); - assert.deepEqual(resolvedEvents.map((event) => event.type).toSorted(), [ + NodeAssert.deepEqual(resolvedEvents.map((event) => event.type).toSorted(), [ "request.resolved", "user-input.resolved", ]); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.equal(permissionResult, ToolConfirmationOutcome.Cancel); - assert.deepEqual(userInputResult, { cancelled: true, answers: [] }); + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.equal(permissionResult, ToolConfirmationOutcome.Cancel); + NodeAssert.deepEqual(userInputResult, { cancelled: true, answers: [] }); const resolvedApproval = resolvedEvents.find((event) => event.type === "request.resolved"); - assert.equal(resolvedApproval?.type, "request.resolved"); + NodeAssert.equal(resolvedApproval?.type, "request.resolved"); if (resolvedApproval?.type === "request.resolved") { - assert.equal(resolvedApproval.payload.decision, "cancel"); + NodeAssert.equal(resolvedApproval.payload.decision, "cancel"); } const resolvedUserInput = resolvedEvents.find( (event) => event.type === "user-input.resolved", ); - assert.equal(resolvedUserInput?.type, "user-input.resolved"); + NodeAssert.equal(resolvedUserInput?.type, "user-input.resolved"); if (resolvedUserInput?.type === "user-input.resolved") { - assert.deepEqual(resolvedUserInput.payload.answers, {}); + NodeAssert.deepEqual(resolvedUserInput.payload.answers, {}); } }), ).pipe(Effect.provide(testLayer)), @@ -969,12 +970,12 @@ it.effect("continues stopping Droid sessions when one close fails", () => yield* adapter.stopAll(); - assert.deepEqual(closedSessionIds.toSorted(), [ + NodeAssert.deepEqual(closedSessionIds.toSorted(), [ "droid-session-closes", "droid-session-fails-close", ]); const sessions = yield* adapter.listSessions(); - assert.deepEqual(sessions, []); + NodeAssert.deepEqual(sessions, []); }), ).pipe(Effect.provide(testLayer)), ); @@ -1016,8 +1017,8 @@ it.effect("marks Droid stream errors as failed turns", () => const runtimeError = events.find((event) => event.type === "runtime.error"); const turnCompleted = events.find((event) => event.type === "turn.completed"); - assert.equal(runtimeError?.type, "runtime.error"); - assert.deepEqual(turnCompleted?.payload, { + NodeAssert.equal(runtimeError?.type, "runtime.error"); + NodeAssert.deepEqual(turnCompleted?.payload, { state: "failed", errorMessage: "Droid stream failed", }); @@ -1073,11 +1074,11 @@ it.effect("marks aborted Droid turns as interrupted without runtime error", () = yield* adapter.interruptTurn(threadId); const events = yield* joinIterableFiber(eventsFiber); - assert.equal( + NodeAssert.equal( events.some((event) => event.type === "runtime.error"), false, ); - assert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { + NodeAssert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { state: "interrupted", }); }), @@ -1100,7 +1101,7 @@ it.effect("reads Droid thread snapshots and rejects unsupported rollback", () => const missing = yield* adapter .readThread(ThreadId.make("missing-droid-thread")) .pipe(Effect.exit); - assert.equal(missing._tag, "Failure"); + NodeAssert.equal(missing._tag, "Failure"); yield* adapter.startSession({ threadId, @@ -1122,17 +1123,17 @@ it.effect("reads Droid thread snapshots and rejects unsupported rollback", () => yield* Fiber.join(secondCompleted).pipe(Effect.timeout("2 seconds")); const before = yield* adapter.readThread(threadId); - assert.equal(before.turns.length, 2); + NodeAssert.equal(before.turns.length, 2); const rollback = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.exit); - assert.equal(rollback._tag, "Failure"); + NodeAssert.equal(rollback._tag, "Failure"); if (rollback._tag === "Failure") { - assert.match(String(rollback.cause), /provider-native rewind\/fork support/); + NodeAssert.match(String(rollback.cause), /provider-native rewind\/fork support/); } const after = yield* adapter.readThread(threadId); - assert.equal(after.turns.length, 2); + NodeAssert.equal(after.turns.length, 2); const invalid = yield* adapter.rollbackThread(threadId, 0).pipe(Effect.exit); - assert.equal(invalid._tag, "Failure"); + NodeAssert.equal(invalid._tag, "Failure"); }), ).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index 07def79c4753..754c04cceaab 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { type AskUserRequestParams, type AskUserResult, @@ -148,7 +148,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter resolve(ToolConfirmationOutcome.Cancel); return; } - const requestId = ApprovalRequestId.make(`droid-${randomUUID()}`); + const requestId = ApprovalRequestId.make(`droid-${NodeCrypto.randomUUID()}`); const requestType = toRequestType(params); context.pendingPermissions.set(requestId, { requestType, resolve }); void emitNow({ @@ -169,7 +169,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter resolve({ cancelled: true, answers: [] }); return; } - const requestId = ApprovalRequestId.make(`droid-question-${randomUUID()}`); + const requestId = ApprovalRequestId.make(`droid-question-${NodeCrypto.randomUUID()}`); const questions = normalizeAskUserQuestions(params); context.pendingUserInputs.set(requestId, { questions, @@ -291,7 +291,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }); } - const turnId = TurnId.make(`droid-turn-${randomUUID()}`); + const turnId = TurnId.make(`droid-turn-${NodeCrypto.randomUUID()}`); const abort = new AbortController(); context.activeAbort = abort; context.activeAssistantItems = new Map(); diff --git a/apps/server/src/provider/Layers/DroidProvider.ts b/apps/server/src/provider/Layers/DroidProvider.ts index 413e9a8cac64..e4cc5515eb48 100644 --- a/apps/server/src/provider/Layers/DroidProvider.ts +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -6,7 +6,7 @@ import { ModelProvider, ReasoningEffort, } from "@factory/droid-sdk"; -import { tmpdir } from "node:os"; +import * as NodeOS from "node:os"; import { type DroidSettings, ProviderDriverKind, @@ -193,7 +193,7 @@ export const discoverDroidModels = ( void (async () => { try { session = await (options?.sdk ?? defaultSdk).createSession({ - cwd: tmpdir(), + cwd: NodeOS.tmpdir(), execPath: settings.binaryPath, env: compactEnvironment(environment), abortSignal: abort.signal, @@ -280,6 +280,7 @@ export function checkDroidProviderStatus( const command = ChildProcess.make(settings.binaryPath, ["--version"], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probe is a pure process spawn outside the Effect runtime service graph. shell: process.platform === "win32", }); const result = yield* spawnAndCollect(settings.binaryPath, command).pipe( diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts index e17382ad2e66..6ea76d401ac4 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { ApprovalRequestId, @@ -132,8 +133,8 @@ it.effect("delegates session startup to the manager", () => runtimeMode: "full-access", }); - assert.equal(session.provider, "geminiCli"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + NodeAssert.equal(session.provider, "geminiCli"); + NodeAssert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }).pipe(Effect.scoped), ); @@ -148,9 +149,9 @@ it.effect("returns validation error when the provider is disabled", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") return; - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe( Effect.provide(makeAdapterLayer(new FakeGeminiCliManager(), disabledConfig)), Effect.scoped, @@ -168,11 +169,11 @@ it.effect("rejects attachments until Gemini CLI attachment wiring exists", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.provide(makeAdapterLayer(new FakeGeminiCliManager())), Effect.scoped), ); @@ -202,14 +203,14 @@ it.effect("forwards manager runtime events through the adapter stream", () => const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); + NodeAssert.equal(received._tag, "Some"); if (received._tag !== "Some") { return; } - assert.equal(received.value.type, "content.delta"); + NodeAssert.equal(received.value.type, "content.delta"); if (received.value.type !== "content.delta") { return; } - assert.equal(received.value.payload.delta, "hello"); + NodeAssert.equal(received.value.payload.delta, "hello"); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Layers/GeminiCliProvider.test.ts b/apps/server/src/provider/Layers/GeminiCliProvider.test.ts index 4d691bb4c53a..843e125fce66 100644 --- a/apps/server/src/provider/Layers/GeminiCliProvider.test.ts +++ b/apps/server/src/provider/Layers/GeminiCliProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as Schema from "effect/Schema"; import { describe, it } from "vite-plus/test"; @@ -20,11 +20,11 @@ describe("GeminiCliProvider capabilities", () => { const builtIn = draft.models.find((model) => !model.isCustom); if (!builtIn) { - assert.fail("expected a built-in gemini model"); + NodeAssert.fail("expected a built-in gemini model"); } const descriptors = builtIn.capabilities?.optionDescriptors ?? []; - assert.ok( + NodeAssert.ok( !descriptors.some((descriptor) => descriptor.id === "thinkingBudget"), "thinkingBudget was inert; keep it removed until wired to the Gemini CLI", ); diff --git a/apps/server/src/provider/Layers/GeminiCliProvider.ts b/apps/server/src/provider/Layers/GeminiCliProvider.ts index 79b19ea85bc4..e5bc24904969 100644 --- a/apps/server/src/provider/Layers/GeminiCliProvider.ts +++ b/apps/server/src/provider/Layers/GeminiCliProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * GeminiCliProvider — snapshot probe for the Gemini CLI provider. * @@ -91,6 +92,7 @@ const runGeminiCommand = Effect.fn("runGeminiCommand")(function* ( const binaryPath = resolveBinary(config); const command = ChildProcess.make(binaryPath, [...args], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index 1fe0f7c96942..395508a8fff9 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { EventId, @@ -91,8 +92,8 @@ it.effect("makeKiloAdapter delegates session startup to the manager", () => runtimeMode: "full-access", }); - assert.equal(session.provider, "kilo"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + NodeAssert.equal(session.provider, "kilo"); + NodeAssert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }), ), ); @@ -110,11 +111,11 @@ it.effect("makeKiloAdapter rejects attachments until Kilo wiring exists", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }), ), ); @@ -143,15 +144,15 @@ it.effect("makeKiloAdapter forwards manager runtime events through the stream", const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); + NodeAssert.equal(received._tag, "Some"); if (received._tag !== "Some") { return; } - assert.equal(received.value.type, "content.delta"); + NodeAssert.equal(received.value.type, "content.delta"); if (received.value.type !== "content.delta") { return; } - assert.equal(received.value.payload.delta, "hello"); + NodeAssert.equal(received.value.payload.delta, "hello"); }), ), ); @@ -169,11 +170,11 @@ it.effect("makeKiloAdapter rejects startSession when disabled", () => .startSession({ threadId: asThreadId("thread-disabled"), runtimeMode: "full-access" }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }), ), ); diff --git a/apps/server/src/provider/Layers/KiloProvider.test.ts b/apps/server/src/provider/Layers/KiloProvider.test.ts index 6edf9976766e..d39b6e080298 100644 --- a/apps/server/src/provider/Layers/KiloProvider.test.ts +++ b/apps/server/src/provider/Layers/KiloProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; @@ -19,21 +19,21 @@ describe("KiloProvider model discovery mapping", () => { { slug: "anthropic/claude", name: "Anthropic / Claude", connected: false }, ]); - assert.equal(result.length, 2); + NodeAssert.equal(result.length, 2); const [first, second] = result; if (!first || !second) { - assert.fail("expected two mapped models"); + NodeAssert.fail("expected two mapped models"); } - assert.equal(first.slug, "openai/gpt-5"); - assert.equal(first.name, "OpenAI / GPT-5"); - assert.equal(first.isCustom, false); - assert.ok(first.capabilities, "discovered models carry default capabilities"); - assert.equal(second.slug, "anthropic/claude"); - assert.equal(second.isCustom, false); + NodeAssert.equal(first.slug, "openai/gpt-5"); + NodeAssert.equal(first.name, "OpenAI / GPT-5"); + NodeAssert.equal(first.isCustom, false); + NodeAssert.ok(first.capabilities, "discovered models carry default capabilities"); + NodeAssert.equal(second.slug, "anthropic/claude"); + NodeAssert.equal(second.isCustom, false); }); it("returns an empty list when nothing is discovered", () => { - assert.deepEqual(kiloDiscoveredToServerModels([]), []); + NodeAssert.deepEqual(kiloDiscoveredToServerModels([]), []); }); }); diff --git a/apps/server/src/provider/Layers/KiloProvider.ts b/apps/server/src/provider/Layers/KiloProvider.ts index da8e023809b0..44fc41b04413 100644 --- a/apps/server/src/provider/Layers/KiloProvider.ts +++ b/apps/server/src/provider/Layers/KiloProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * KiloProvider — snapshot probe for the Kilo Code provider. * @@ -113,6 +114,7 @@ const runKiloCommand = Effect.fn("runKiloCommand")(function* ( const binaryPath = kiloSettings.binaryPath.trim() || "kilo"; const command = ChildProcess.make(binaryPath, [...args], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/copilotCliPath.ts b/apps/server/src/provider/Layers/copilotCliPath.ts index 489eb3b67945..a1eeb878d762 100644 --- a/apps/server/src/provider/Layers/copilotCliPath.ts +++ b/apps/server/src/provider/Layers/copilotCliPath.ts @@ -1,10 +1,11 @@ -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); +// @effect-diagnostics nodeBuiltinImport:off - Pure Copilot CLI path resolver inspects packaged Node resources. +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; + +const require = NodeModule.createRequire(import.meta.url); +const CURRENT_DIR = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const GITHUB_SCOPE_DIR = "@github"; const COPILOT_PATHLESS_COMMAND_PATTERN = /^copilot(?:\.(?:exe|cmd|bat))?$/i; const COPILOT_DESKTOP_ENV_BLOCKLIST = [ @@ -102,7 +103,7 @@ function resolveGithubScopeDirFromSdkEntrypoint( sdkEntrypoint: string | undefined, ): string | undefined { if (!sdkEntrypoint) return undefined; - return join(dirname(dirname(sdkEntrypoint)), ".."); + return NodePath.join(NodePath.dirname(NodePath.dirname(sdkEntrypoint)), ".."); } function resolveNodeModulesRoots(input: { @@ -112,13 +113,15 @@ function resolveNodeModulesRoots(input: { }): string[] { const githubScopeDir = resolveGithubScopeDirFromSdkEntrypoint(input.sdkEntrypoint); return dedupePaths([ - input.resourcesPath ? join(input.resourcesPath, "app.asar.unpacked/node_modules") : undefined, - input.resourcesPath ? join(input.resourcesPath, "node_modules") : undefined, - join(input.currentDir, "../../../../../app.asar.unpacked/node_modules"), - join(input.currentDir, "../../../../../../app.asar.unpacked/node_modules"), - join(input.currentDir, "../../../node_modules"), - join(input.currentDir, "../../../../../node_modules"), - githubScopeDir ? join(githubScopeDir, "..") : undefined, + input.resourcesPath + ? NodePath.join(input.resourcesPath, "app.asar.unpacked/node_modules") + : undefined, + input.resourcesPath ? NodePath.join(input.resourcesPath, "node_modules") : undefined, + NodePath.join(input.currentDir, "../../../../../app.asar.unpacked/node_modules"), + NodePath.join(input.currentDir, "../../../../../../app.asar.unpacked/node_modules"), + NodePath.join(input.currentDir, "../../../node_modules"), + NodePath.join(input.currentDir, "../../../../../node_modules"), + githubScopeDir ? NodePath.join(githubScopeDir, "..") : undefined, ]); } @@ -127,7 +130,9 @@ function getCopilotPlatformBinaryName(platform: string): string { } export function getBundledCopilotPlatformPackages( + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps platform injectable for tests and non-Effect callers. platform: string = process.platform, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps architecture injectable for tests and non-Effect callers. arch: string = process.arch, ): ReadonlyArray { if (platform === "darwin" && arch === "arm64") { @@ -160,9 +165,11 @@ export function resolveBundledCopilotCliPathFrom(input: { arch?: string; exists?: (path: string) => boolean; }): string | undefined { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps platform injectable for tests and non-Effect callers. const platform = input.platform ?? process.platform; + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps architecture injectable for tests and non-Effect callers. const arch = input.arch ?? process.arch; - const exists = input.exists ?? existsSync; + const exists = input.exists ?? NodeFS.existsSync; const sdkEntrypoint = input.sdkEntrypoint; const nodeModulesRoots = resolveNodeModulesRoots({ currentDir: input.currentDir, @@ -173,7 +180,9 @@ export function resolveBundledCopilotCliPathFrom(input: { const platformPackages = getBundledCopilotPlatformPackages(platform, arch); const binaryCandidates = nodeModulesRoots.flatMap((root) => - platformPackages.map((packageName) => join(root, GITHUB_SCOPE_DIR, packageName, binaryName)), + platformPackages.map((packageName) => + NodePath.join(root, GITHUB_SCOPE_DIR, packageName, binaryName), + ), ); for (const candidate of dedupePaths(binaryCandidates)) { if (exists(candidate)) { @@ -187,7 +196,7 @@ export function resolveBundledCopilotCliPathFrom(input: { } const sdkSiblingBinaryCandidates = platformPackages.map((packageName) => - join(githubScopeDir, packageName, binaryName), + NodePath.join(githubScopeDir, packageName, binaryName), ); for (const candidate of dedupePaths(sdkSiblingBinaryCandidates)) { if (exists(candidate)) { diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts index fb9f32358fb9..e35caee5cd42 100644 --- a/apps/server/src/provider/droid/DroidRuntimeEvents.ts +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { DroidMessageType, type DroidMessage } from "@factory/droid-sdk"; import { EventId, @@ -37,7 +37,7 @@ export function makeDroidEventBase(instanceId: ProviderInstanceId) { raw?: unknown; }, ) => ({ - eventId: EventId.make(randomUUID()), + eventId: EventId.make(NodeCrypto.randomUUID()), provider: DROID_PROVIDER, providerInstanceId: instanceId, threadId: context.session.threadId, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e482920a48a0..fe659c2b3187 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - VCS driver uses Node path helpers at the process boundary. import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; @@ -18,7 +19,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import nodePath from "node:path"; +import * as NodePath from "node:path"; import { GitCommandError, @@ -789,7 +790,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* kind: "client", attributes: { "git.operation": input.operation, - "git.repo": nodePath.basename(input.cwd), + "git.repo": NodePath.basename(input.cwd), "git.args_count": input.args.length, }, }), diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 0178735666e1..8ed5238bd6b9 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -1,4 +1,4 @@ -import { createRequire } from "node:module"; +import * as NodeModule from "node:module"; import "vite-plus/test/config"; import { defineConfig, mergeConfig } from "vite-plus"; @@ -17,7 +17,7 @@ const bundledPackagePrefixes = [ "@opencode-ai/", ]; -const require = createRequire(import.meta.url); +const require = NodeModule.createRequire(import.meta.url); // @github/copilot-sdk ships an ESM build that imports "vscode-jsonrpc/node" // without the `.js` extension. Under Node's nodenext resolver this throws diff --git a/scripts/lib/macos-icon-composer.ts b/scripts/lib/macos-icon-composer.ts index 668ca14e7b18..2740c9138aae 100644 --- a/scripts/lib/macos-icon-composer.ts +++ b/scripts/lib/macos-icon-composer.ts @@ -1,7 +1,8 @@ -import { spawnSync } from "node:child_process"; -import { cp, mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; +// @effect-diagnostics nodeBuiltinImport:off - Standalone icon asset compiler shells out to actool and reads generated files. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; export interface CompiledMacIconAsset { readonly assetCatalog: Buffer; @@ -14,7 +15,7 @@ function parseActoolVersion(rawOutput: string): string | null { } function assertSupportedActoolVersion(): void { - const result = spawnSync("actool", ["--version"], { + const result = NodeChildProcess.spawnSync("actool", ["--version"], { encoding: "utf8", }); const version = parseActoolVersion(`${result.stdout ?? ""}${result.stderr ?? ""}`); @@ -38,15 +39,17 @@ export async function generateAssetCatalogForIcon( ): Promise { assertSupportedActoolVersion(); - const tempRoot = await mkdtemp(resolve(tmpdir(), "t3code-icon-composer-")); - const iconPath = resolve(tempRoot, "Icon.icon"); - const outputPath = resolve(tempRoot, "out"); + const tempRoot = await NodeFSP.mkdtemp( + NodePath.resolve(NodeOS.tmpdir(), "t3code-icon-composer-"), + ); + const iconPath = NodePath.resolve(tempRoot, "Icon.icon"); + const outputPath = NodePath.resolve(tempRoot, "out"); try { - await cp(inputPath, iconPath, { recursive: true }); - await mkdir(outputPath, { recursive: true }); + await NodeFSP.cp(inputPath, iconPath, { recursive: true }); + await NodeFSP.mkdir(outputPath, { recursive: true }); - const result = spawnSync( + const result = NodeChildProcess.spawnSync( "actool", [ iconPath, @@ -57,7 +60,7 @@ export async function generateAssetCatalogForIcon( "--notices", "--warnings", "--output-partial-info-plist", - resolve(outputPath, "assetcatalog_generated_info.plist"), + NodePath.resolve(outputPath, "assetcatalog_generated_info.plist"), "--app-icon", "Icon", "--include-all-app-icons", @@ -86,10 +89,10 @@ export async function generateAssetCatalogForIcon( } return { - assetCatalog: await readFile(resolve(outputPath, "Assets.car")), - icnsFile: await readFile(resolve(outputPath, "Icon.icns")), + assetCatalog: await NodeFSP.readFile(NodePath.resolve(outputPath, "Assets.car")), + icnsFile: await NodeFSP.readFile(NodePath.resolve(outputPath, "Icon.icns")), }; } finally { - await rm(tempRoot, { recursive: true, force: true }); + await NodeFSP.rm(tempRoot, { recursive: true, force: true }); } } diff --git a/scripts/sync-upstream-pr-tracks.mjs b/scripts/sync-upstream-pr-tracks.mjs index 0a75b8ad9add..a5da4308ac3a 100644 --- a/scripts/sync-upstream-pr-tracks.mjs +++ b/scripts/sync-upstream-pr-tracks.mjs @@ -1,14 +1,14 @@ #!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import { execFileSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; -const repoRoot = path.resolve(import.meta.dirname, ".."); -const configPath = path.join(repoRoot, "config", "upstream-pr-tracks.json"); +const repoRoot = NodePath.resolve(import.meta.dirname, ".."); +const configPath = NodePath.join(repoRoot, "config", "upstream-pr-tracks.json"); function runGit(args, options = {}) { - const output = execFileSync("git", args, { + const output = NodeChildProcess.execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], @@ -48,7 +48,7 @@ function deriveRepoUrl(remoteName) { } function loadConfig() { - const raw = fs.readFileSync(configPath, "utf8"); + const raw = NodeFS.readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { throw new Error("Invalid upstream PR tracking config."); From 0349fd14f7a67b1bf52070bbc1dc3e77de087fa4 Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Wed, 24 Jun 2026 12:30:42 +0530 Subject: [PATCH 80/80] Format runtime diagnostics files --- apps/web/src/components/Sidebar.tsx | 9 +++++---- apps/web/src/environmentBootstrap.ts | 5 +---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1f6bd3ddffdd..515c0013eddc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3126,10 +3126,11 @@ export default function Sidebar() { ); const sidebarThreadsWithActivitiesAtom = useMemo( () => - Atom.make((get): ReadonlyArray> => - sidebarThreadRefs.map((threadRef) => ({ - activities: get(environmentThreadDetails.activitiesAtom(threadRef)), - })), + Atom.make( + (get): ReadonlyArray> => + sidebarThreadRefs.map((threadRef) => ({ + activities: get(environmentThreadDetails.activitiesAtom(threadRef)), + })), ), [sidebarThreadRefs], ); diff --git a/apps/web/src/environmentBootstrap.ts b/apps/web/src/environmentBootstrap.ts index e5b16a9f0390..720939da18eb 100644 --- a/apps/web/src/environmentBootstrap.ts +++ b/apps/web/src/environmentBootstrap.ts @@ -1,7 +1,4 @@ -import { - createKnownEnvironment, - type KnownEnvironment, -} from "@t3tools/client-runtime/environment"; +import { createKnownEnvironment, type KnownEnvironment } from "@t3tools/client-runtime/environment"; import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; function normalizeBaseUrl(rawValue: string): string {