From fa3d3095d9100d89cc121827229318257024bda8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:13:38 -0700 Subject: [PATCH] fix(server): finish process and preview Effect cleanup Co-authored-by: codex --- apps/server/src/preview/PortScanner.test.ts | 4 +- .../src/process/externalLauncher.test.ts | 20 ++--- apps/server/src/process/externalLauncher.ts | 59 +++++++++++--- apps/server/src/server.test.ts | 7 +- packages/contracts/src/editor.ts | 78 +++++++++++++++++-- 5 files changed, 139 insertions(+), 29 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 481d28d782f..9216c696008 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -6,9 +6,9 @@ import * as Net from "@t3tools/shared/Net"; import { Effect, Layer } from "effect"; import { expect } from "vite-plus/test"; -import { ProcessRunner } from "../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; -const TestProcessRunner = Layer.succeed(ProcessRunner, { +const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); const TestPortDiscoveryLive = PortScanner.layer.pipe( diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 0a157e301c4..43ca40e9c7c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -11,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; -import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; +import * as ExternalLauncher from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ @@ -54,7 +54,7 @@ const testLayer = (input: { ); return Layer.mergeAll( - ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), + ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), Layer.succeed( SpawnExecutableResolution, @@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => { let spawned: ChildProcess.StandardCommand | undefined; let didUnref = false; return Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchBrowser("https://example.com/some path"); @@ -101,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchEditor({ editor: "vscode", cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", @@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () => yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; return yield* launcher.resolveAvailableEditors(); }).pipe( Effect.provide( @@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () => it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { - const launcher = yield* ExternalLauncher; - const result = yield* launcher + const launcher = yield* ExternalLauncher.ExternalLauncher; + const error = yield* launcher .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) - .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + .pipe(Effect.flip); + assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); + assert.equal(error.editor, "missing-editor"); + assert.equal(error.message, "Unknown editor: missing-editor"); }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index e8cfce0e96a..9c2f0e417d3 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -9,6 +9,11 @@ import { EDITORS, ExternalLauncherError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherEditorSpawnError, + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; @@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne // Definitions // ============================== -export { ExternalLauncherError }; +export { + ExternalLauncherError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherEditorSpawnError, + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, + isExternalLauncherError, +} from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { + readonly editor: EditorId; + readonly target: string; readonly command: string; readonly args: ReadonlyArray; } @@ -317,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }); const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { - return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); + return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor }); } if (editorDef.commands) { @@ -326,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( () => editorDef.commands[0], ); return { + editor: editorDef.id, + target: input.cwd, command, args: resolveEditorArgs(editorDef, input.cwd), }; } if (editorDef.id !== "file-manager") { - return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } - return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; + return { + editor: editorDef.id, + target: input.cwd, + command: fileManagerCommandForPlatform(platform), + args: [input.cwd], + }; }); const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, - errorMessage: string, + onError: (cause: unknown) => ExternalLauncherError, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make(launch.command, launch.args, launch.options); @@ -349,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( Effect.flatMap((handle) => handle.unref), Effect.asVoid, Effect.scoped, - Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })), + Effect.mapError(onError), ); }); @@ -357,7 +379,16 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { const launch = yield* resolveBrowserLaunch(target); - return yield* launchAndUnref(launch, "Browser auto-open failed"); + return yield* launchAndUnref( + launch, + (cause) => + new ExternalLauncherBrowserSpawnError({ + target, + command: launch.command, + args: launch.args, + cause, + }), + ); }); const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( @@ -369,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu > { const env = yield* readCommandLookupEnv; if (!(yield* isCommandAvailable(launch.command, { env }))) { - return yield* new ExternalLauncherError({ - message: `Editor command not found: ${launch.command}`, + return yield* new ExternalLauncherCommandNotFoundError({ + editor: launch.editor, + command: launch.command, }); } @@ -387,7 +419,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu stderr: "ignore", }, }, - "failed to spawn detached process", + (cause) => + new ExternalLauncherEditorSpawnError({ + editor: launch.editor, + target: launch.target, + command: spawnCommand.command, + args: spawnCommand.args, + cause, + }), ); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 62ad99b3e9c..1529285e50c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -14,7 +14,7 @@ import { GitCommandError, KeybindingRule, MessageId, - ExternalLauncherError, + ExternalLauncherCommandNotFoundError, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -4570,8 +4570,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc shell.openInEditor errors", () => Effect.gen(function* () { - const externalLauncherError = new ExternalLauncherError({ - message: "Editor command not found: cursor", + const externalLauncherError = new ExternalLauncherCommandNotFoundError({ + editor: "cursor", + command: "cursor", }); yield* buildAppUnderTest({ layers: { diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index c180cf24294..5948d87e1d2 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; -export class ExternalLauncherError extends Schema.TaggedErrorClass()( - "ExternalLauncherError", +export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass()( + "ExternalLauncherUnknownEditorError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), + editor: Schema.String, }, -) {} +) { + override get message(): string { + return `Unknown editor: ${this.editor}`; + } +} + +export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedErrorClass()( + "ExternalLauncherUnsupportedEditorError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `Unsupported editor: ${this.editor}`; + } +} + +export class ExternalLauncherCommandNotFoundError extends Schema.TaggedErrorClass()( + "ExternalLauncherCommandNotFoundError", + { + editor: EditorId, + command: Schema.String, + }, +) { + override get message(): string { + return `Editor command not found: ${this.command}`; + } +} + +const ExternalLauncherSpawnFields = { + command: Schema.String, + args: Schema.Array(Schema.String), + cause: Schema.Defect(), +}; + +export class ExternalLauncherBrowserSpawnError extends Schema.TaggedErrorClass()( + "ExternalLauncherBrowserSpawnError", + { + ...ExternalLauncherSpawnFields, + target: Schema.String, + }, +) { + override get message(): string { + return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`; + } +} + +export class ExternalLauncherEditorSpawnError extends Schema.TaggedErrorClass()( + "ExternalLauncherEditorSpawnError", + { + ...ExternalLauncherSpawnFields, + editor: EditorId, + target: Schema.String, + }, +) { + override get message(): string { + return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`; + } +} + +export const ExternalLauncherError = Schema.Union([ + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherEditorSpawnError, +]); +export type ExternalLauncherError = typeof ExternalLauncherError.Type; + +export const isExternalLauncherError = Schema.is(ExternalLauncherError);