| author | |
| committer | |
| log | 8ab72e0964d95ca0ef139fba9994e3fb925343c9 |
| tree | 991bb078087550e3672e51bb169462b412777db8 |
| parent | 7ad5d03948a27e64bb11f6862d2f23333a0a2bd1 |
7 files changed, 144 insertions(+), 120 deletions(-)
apps/server/src/terminal/Layers/BunPTY.ts+4-4| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 2 | import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; | 2 | import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; |
| 3 | 3 | ||
| 4 | class BunPtyProcess implements PtyProcess { | 4 | class BunPtyProcess implements PtyProcess { |
| 5 | private readonly dataListeners = new Set<(data: string) => void>(); | 5 | private readonly dataListeners = new Set<(data: string) => void>(); |
| ... | @@ -93,7 +93,7 @@ export const BunPtyAdapterLive = Layer.effect( | ... | @@ -93,7 +93,7 @@ export const BunPtyAdapterLive = Layer.effect( |
| 93 | return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); | 93 | return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); |
| 94 | } | 94 | } |
| 95 | return { | 95 | return { |
| 96 | spawn(input: PtySpawnInput): PtyProcess { | 96 | spawn: (input) => { |
| 97 | let processHandle: BunPtyProcess | null = null; | 97 | let processHandle: BunPtyProcess | null = null; |
| 98 | const command = [input.shell, ...(input.args ?? [])]; | 98 | const command = [input.shell, ...(input.args ?? [])]; |
| 99 | const subprocess = Bun.spawn(command, { | 99 | const subprocess = Bun.spawn(command, { |
| ... | @@ -108,8 +108,8 @@ export const BunPtyAdapterLive = Layer.effect( | ... | @@ -108,8 +108,8 @@ export const BunPtyAdapterLive = Layer.effect( |
| 108 | }, | 108 | }, |
| 109 | }); | 109 | }); |
| 110 | processHandle = new BunPtyProcess(subprocess); | 110 | processHandle = new BunPtyProcess(subprocess); |
| 111 | return processHandle; | 111 | return Effect.succeed(processHandle); |
| 112 | }, | 112 | }, |
| 113 | }; | 113 | } satisfies PtyAdapterShape; |
| 114 | }), | 114 | }), |
| 115 | ); | 115 | ); |
apps/server/src/terminal/Layers/Manager.test.ts+3-3| ... | @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it } from "vitest"; | ... | @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it } from "vitest"; |
| 11 | 11 | ||
| 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; | 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; |
| 13 | import { TerminalManagerRuntime } from "./Manager"; | 13 | import { TerminalManagerRuntime } from "./Manager"; |
| 14 | import { Encoding } from "effect"; | 14 | import { Effect, Encoding } from "effect"; |
| 15 | 15 | ||
| 16 | class FakePtyProcess implements PtyProcess { | 16 | class FakePtyProcess implements PtyProcess { |
| 17 | readonly writes: string[] = []; | 17 | readonly writes: string[] = []; |
| ... | @@ -69,7 +69,7 @@ class FakePtyAdapter implements PtyAdapterShape { | ... | @@ -69,7 +69,7 @@ class FakePtyAdapter implements PtyAdapterShape { |
| 69 | readonly spawnFailures: Error[] = []; | 69 | readonly spawnFailures: Error[] = []; |
| 70 | private nextPid = 9000; | 70 | private nextPid = 9000; |
| 71 | 71 | ||
| 72 | spawn(input: PtySpawnInput): PtyProcess { | 72 | spawn(input: PtySpawnInput): Effect.Effect<PtyProcess> { |
| 73 | this.spawnInputs.push(input); | 73 | this.spawnInputs.push(input); |
| 74 | const failure = this.spawnFailures.shift(); | 74 | const failure = this.spawnFailures.shift(); |
| 75 | if (failure) { | 75 | if (failure) { |
| ... | @@ -77,7 +77,7 @@ class FakePtyAdapter implements PtyAdapterShape { | ... | @@ -77,7 +77,7 @@ class FakePtyAdapter implements PtyAdapterShape { |
| 77 | } | 77 | } |
| 78 | const process = new FakePtyProcess(this.nextPid++); | 78 | const process = new FakePtyProcess(this.nextPid++); |
| 79 | this.processes.push(process); | 79 | this.processes.push(process); |
| 80 | return process; | 80 | return Effect.succeed(process); |
| 81 | } | 81 | } |
| 82 | } | 82 | } |
| 83 | 83 |
apps/server/src/terminal/Layers/Manager.ts+50-20| ... | @@ -20,6 +20,7 @@ import { runProcess } from "../../processRunner"; | ... | @@ -20,6 +20,7 @@ import { runProcess } from "../../processRunner"; |
| 20 | import { ServerConfig } from "../../config"; | 20 | import { ServerConfig } from "../../config"; |
| 21 | import { | 21 | import { |
| 22 | ShellCandidate, | 22 | ShellCandidate, |
| 23 | TerminalError, | ||
| 23 | TerminalManager, | 24 | TerminalManager, |
| 24 | TerminalManagerShape, | 25 | TerminalManagerShape, |
| 25 | TerminalSessionState, | 26 | TerminalSessionState, |
| ... | @@ -552,14 +553,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -552,14 +553,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 552 | 553 | ||
| 553 | for (const candidate of shellCandidates) { | 554 | for (const candidate of shellCandidates) { |
| 554 | try { | 555 | try { |
| 555 | ptyProcess = this.ptyAdapter.spawn({ | 556 | ptyProcess = Effect.runSync( |
| 556 | shell: candidate.shell, | 557 | this.ptyAdapter.spawn({ |
| 557 | ...(candidate.args ? { args: candidate.args } : {}), | 558 | shell: candidate.shell, |
| 558 | cwd: session.cwd, | 559 | ...(candidate.args ? { args: candidate.args } : {}), |
| 559 | cols: session.cols, | 560 | cwd: session.cwd, |
| 560 | rows: session.rows, | 561 | cols: session.cols, |
| 561 | env: terminalEnv, | 562 | rows: session.rows, |
| 562 | }); | 563 | env: terminalEnv, |
| 564 | }), | ||
| 565 | ); | ||
| 563 | startedShell = formatShellCandidate(candidate); | 566 | startedShell = formatShellCandidate(candidate); |
| 564 | break; | 567 | break; |
| 565 | } catch (error) { | 568 | } catch (error) { |
| ... | @@ -690,7 +693,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -690,7 +693,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 690 | this.killEscalationTimers.delete(process); | 693 | this.killEscalationTimers.delete(process); |
| 691 | } | 694 | } |
| 692 | 695 | ||
| 693 | private killProcessWithEscalation(process: PtyProcess, threadId: string, terminalId: string): void { | 696 | private killProcessWithEscalation( |
| 697 | process: PtyProcess, | ||
| 698 | threadId: string, | ||
| 699 | terminalId: string, | ||
| 700 | ): void { | ||
| 694 | this.clearKillEscalationTimer(process); | 701 | this.clearKillEscalationTimer(process); |
| 695 | try { | 702 | try { |
| 696 | process.kill("SIGTERM"); | 703 | process.kill("SIGTERM"); |
| ... | @@ -724,7 +731,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -724,7 +731,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 724 | } | 731 | } |
| 725 | 732 | ||
| 726 | private evictInactiveSessionsIfNeeded(): void { | 733 | private evictInactiveSessionsIfNeeded(): void { |
| 727 | const inactiveSessions = [...this.sessions.values()].filter((session) => session.status !== "running"); | 734 | const inactiveSessions = [...this.sessions.values()].filter( |
| 735 | (session) => session.status !== "running", | ||
| 736 | ); | ||
| 728 | if (inactiveSessions.length <= this.maxRetainedInactiveSessions) { | 737 | if (inactiveSessions.length <= this.maxRetainedInactiveSessions) { |
| 729 | return; | 738 | return; |
| 730 | } | 739 | } |
| ... | @@ -1110,12 +1119,36 @@ export const TerminalManagerLive = Layer.effect( | ... | @@ -1110,12 +1119,36 @@ export const TerminalManagerLive = Layer.effect( |
| 1110 | ); | 1119 | ); |
| 1111 | 1120 | ||
| 1112 | return { | 1121 | return { |
| 1113 | open: (input) => Effect.promise(() => runtime.open(input)), | 1122 | open: (input) => |
| 1114 | write: (input) => Effect.promise(() => runtime.write(input)), | 1123 | Effect.tryPromise({ |
| 1115 | resize: (input) => Effect.promise(() => runtime.resize(input)), | 1124 | try: () => runtime.open(input), |
| 1116 | clear: (input) => Effect.promise(() => runtime.clear(input)), | 1125 | catch: (cause) => new TerminalError({ message: "Failed to open terminal", cause }), |
| 1117 | restart: (input) => Effect.promise(() => runtime.restart(input)), | 1126 | }), |
| 1118 | close: (input) => Effect.promise(() => runtime.close(input)), | 1127 | write: (input) => |
| 1128 | Effect.tryPromise({ | ||
| 1129 | try: () => runtime.write(input), | ||
| 1130 | catch: (cause) => new TerminalError({ message: "Failed to write to terminal", cause }), | ||
| 1131 | }), | ||
| 1132 | resize: (input) => | ||
| 1133 | Effect.tryPromise({ | ||
| 1134 | try: () => runtime.resize(input), | ||
| 1135 | catch: (cause) => new TerminalError({ message: "Failed to resize terminal", cause }), | ||
| 1136 | }), | ||
| 1137 | clear: (input) => | ||
| 1138 | Effect.tryPromise({ | ||
| 1139 | try: () => runtime.clear(input), | ||
| 1140 | catch: (cause) => new TerminalError({ message: "Failed to clear terminal", cause }), | ||
| 1141 | }), | ||
| 1142 | restart: (input) => | ||
| 1143 | Effect.tryPromise({ | ||
| 1144 | try: () => runtime.restart(input), | ||
| 1145 | catch: (cause) => new TerminalError({ message: "Failed to restart terminal", cause }), | ||
| 1146 | }), | ||
| 1147 | close: (input) => | ||
| 1148 | Effect.tryPromise({ | ||
| 1149 | try: () => runtime.close(input), | ||
| 1150 | catch: (cause) => new TerminalError({ message: "Failed to close terminal", cause }), | ||
| 1151 | }), | ||
| 1119 | subscribe: (listener) => | 1152 | subscribe: (listener) => |
| 1120 | Effect.sync(() => { | 1153 | Effect.sync(() => { |
| 1121 | runtime.on("event", listener); | 1154 | runtime.on("event", listener); |
| ... | @@ -1123,10 +1156,7 @@ export const TerminalManagerLive = Layer.effect( | ... | @@ -1123,10 +1156,7 @@ export const TerminalManagerLive = Layer.effect( |
| 1123 | runtime.off("event", listener); | 1156 | runtime.off("event", listener); |
| 1124 | }; | 1157 | }; |
| 1125 | }), | 1158 | }), |
| 1126 | dispose: () => | 1159 | dispose: Effect.sync(() => runtime.dispose()), |
| 1127 | Effect.sync(() => { | ||
| 1128 | runtime.dispose(); | ||
| 1129 | }), | ||
| 1130 | } satisfies TerminalManagerShape; | 1160 | } satisfies TerminalManagerShape; |
| 1131 | }), | 1161 | }), |
| 1132 | ); | 1162 | ); |
apps/server/src/terminal/Layers/NodePTY.test.ts+24-44| ... | @@ -1,65 +1,45 @@ | ... | @@ -1,65 +1,45 @@ |
| 1 | import fs from "node:fs"; | 1 | import { FileSystem, Path, Effect } from "effect"; |
| 2 | import os from "node:os"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import { assert, it, vi } from "@effect/vitest"; | ||
| 6 | import { Effect, Layer } from "effect"; | ||
| 7 | import { afterEach } from "vitest"; | ||
| 8 | 3 | ||
| 9 | import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; | 4 | import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; |
| 5 | import { NodeServices } from "@effect/platform-node"; | ||
| 10 | 6 | ||
| 11 | function fileMode(filePath: string): number { | 7 | it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { |
| 12 | return fs.statSync(filePath).mode & 0o777; | ||
| 13 | } | ||
| 14 | |||
| 15 | const tempDirs: string[] = []; | ||
| 16 | |||
| 17 | afterEach(() => { | ||
| 18 | vi.unstubAllGlobals(); | ||
| 19 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | ||
| 20 | fs.rmSync(dir, { recursive: true, force: true }); | ||
| 21 | } | ||
| 22 | }); | ||
| 23 | |||
| 24 | const helperLayer = it.layer(Layer.empty); | ||
| 25 | |||
| 26 | helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { | ||
| 27 | it.effect("adds executable bits when helper exists but is not executable", () => | 8 | it.effect("adds executable bits when helper exists but is not executable", () => |
| 28 | Effect.sync(() => { | 9 | Effect.gen(function* () { |
| 29 | if (process.platform === "win32") return; | 10 | if (process.platform === "win32") return; |
| 30 | 11 | ||
| 31 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | 12 | const fs = yield* FileSystem.FileSystem; |
| 32 | tempDirs.push(dir); | 13 | const path = yield* Path.Path; |
| 14 | |||
| 15 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); | ||
| 33 | const helperPath = path.join(dir, "spawn-helper"); | 16 | const helperPath = path.join(dir, "spawn-helper"); |
| 34 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | 17 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); |
| 35 | fs.chmodSync(helperPath, 0o644); | 18 | yield* fs.chmod(helperPath, 0o644); |
| 36 | 19 | ||
| 37 | ensureNodePtySpawnHelperExecutable(helperPath); | 20 | yield* ensureNodePtySpawnHelperExecutable(helperPath); |
| 38 | 21 | ||
| 39 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | 22 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; |
| 23 | assert.equal(mode & 0o111, 0o111); | ||
| 40 | }), | 24 | }), |
| 41 | ); | 25 | ); |
| 42 | 26 | ||
| 43 | it.effect("keeps executable helper as executable", () => | 27 | it.effect("keeps executable helper as executable", () => |
| 44 | Effect.sync(() => { | 28 | Effect.gen(function* () { |
| 45 | if (process.platform === "win32") return; | 29 | if (process.platform === "win32") return; |
| 46 | 30 | ||
| 47 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | 31 | const fs = yield* FileSystem.FileSystem; |
| 48 | tempDirs.push(dir); | 32 | const path = yield* Path.Path; |
| 49 | const helperPath = path.join(dir, "spawn-helper"); | ||
| 50 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | ||
| 51 | fs.chmodSync(helperPath, 0o755); | ||
| 52 | 33 | ||
| 53 | ensureNodePtySpawnHelperExecutable(helperPath); | 34 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); |
| 35 | const helperPath = path.join(dir, "spawn-helper"); | ||
| 36 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); | ||
| 37 | yield* fs.chmod(helperPath, 0o755); | ||
| 54 | 38 | ||
| 55 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | 39 | yield* ensureNodePtySpawnHelperExecutable(helperPath); |
| 56 | }), | ||
| 57 | ); | ||
| 58 | 40 | ||
| 59 | it.effect("does not throw when helper path is missing", () => | 41 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; |
| 60 | Effect.sync(() => { | 42 | assert.equal(mode & 0o111, 0o111); |
| 61 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); | ||
| 62 | ensureNodePtySpawnHelperExecutable(missing); | ||
| 63 | }), | 43 | }), |
| 64 | ); | 44 | ); |
| 65 | }); | 45 | }); |
apps/server/src/terminal/Layers/NodePTY.ts+44-39| ... | @@ -1,55 +1,48 @@ | ... | @@ -1,55 +1,48 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import { createRequire } from "node:module"; | 1 | import { createRequire } from "node:module"; |
| 3 | import path from "node:path"; | ||
| 4 | 2 | ||
| 5 | import { Effect, Layer } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 6 | import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; | 4 | import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; |
| 7 | 5 | ||
| 8 | const requireForNodePty = createRequire(import.meta.url); | ||
| 9 | let didEnsureSpawnHelperExecutable = false; | 6 | let didEnsureSpawnHelperExecutable = false; |
| 10 | 7 | ||
| 11 | function resolveNodePtySpawnHelperPath(): string | null { | 8 | const resolveNodePtySpawnHelperPath = Effect.gen(function* () { |
| 12 | try { | 9 | const requireForNodePty = createRequire(import.meta.url); |
| 13 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | 10 | const path = yield* Path.Path; |
| 14 | const packageDir = path.dirname(packageJsonPath); | 11 | const fs = yield* FileSystem.FileSystem; |
| 15 | const candidates = [ | 12 | |
| 16 | path.join(packageDir, "build", "Release", "spawn-helper"), | 13 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); |
| 17 | path.join(packageDir, "build", "Debug", "spawn-helper"), | 14 | const packageDir = path.dirname(packageJsonPath); |
| 18 | path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), | 15 | const candidates = [ |
| 19 | ]; | 16 | path.join(packageDir, "build", "Release", "spawn-helper"), |
| 20 | 17 | path.join(packageDir, "build", "Debug", "spawn-helper"), | |
| 21 | for (const candidate of candidates) { | 18 | path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), |
| 22 | if (fs.existsSync(candidate)) { | 19 | ]; |
| 23 | return candidate; | 20 | |
| 24 | } | 21 | for (const candidate of candidates) { |
| 22 | if (yield* fs.exists(candidate)) { | ||
| 23 | return candidate; | ||
| 25 | } | 24 | } |
| 26 | |||
| 27 | return null; | ||
| 28 | } catch { | ||
| 29 | return null; | ||
| 30 | } | 25 | } |
| 31 | } | 26 | return null; |
| 27 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 32 | 28 | ||
| 33 | export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void { | 29 | export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { |
| 30 | const fs = yield* FileSystem.FileSystem; | ||
| 34 | if (process.platform === "win32") return; | 31 | if (process.platform === "win32") return; |
| 35 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; | 32 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; |
| 36 | 33 | ||
| 37 | const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath(); | 34 | const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); |
| 38 | if (!helperPath) return; | 35 | if (!helperPath) return; |
| 39 | if (!explicitPath) { | 36 | if (!explicitPath) { |
| 40 | didEnsureSpawnHelperExecutable = true; | 37 | didEnsureSpawnHelperExecutable = true; |
| 41 | } | 38 | } |
| 42 | 39 | ||
| 43 | try { | 40 | const stat = yield* fs.stat(helperPath); |
| 44 | const stat = fs.statSync(helperPath); | 41 | const mode = stat.mode & 0o777; |
| 45 | const mode = stat.mode & 0o777; | 42 | if ((mode & 0o111) === 0) { |
| 46 | if ((mode & 0o111) === 0) { | 43 | yield* fs.chmod(helperPath, mode | 0o111); |
| 47 | fs.chmodSync(helperPath, mode | 0o111); | ||
| 48 | } | ||
| 49 | } catch { | ||
| 50 | // Best effort only. If chmod fails, node-pty spawn will surface the real error. | ||
| 51 | } | 44 | } |
| 52 | } | 45 | }); |
| 53 | 46 | ||
| 54 | class NodePtyProcess implements PtyProcess { | 47 | class NodePtyProcess implements PtyProcess { |
| 55 | constructor(private readonly process: import("node-pty").IPty) {} | 48 | constructor(private readonly process: import("node-pty").IPty) {} |
| ... | @@ -93,10 +86,22 @@ class NodePtyProcess implements PtyProcess { | ... | @@ -93,10 +86,22 @@ class NodePtyProcess implements PtyProcess { |
| 93 | export const NodePtyAdapterLive = Layer.effect( | 86 | export const NodePtyAdapterLive = Layer.effect( |
| 94 | PtyAdapter, | 87 | PtyAdapter, |
| 95 | Effect.gen(function* () { | 88 | Effect.gen(function* () { |
| 89 | const fs = yield* FileSystem.FileSystem; | ||
| 90 | const path = yield* Path.Path; | ||
| 91 | |||
| 96 | const nodePty = yield* Effect.promise(() => import("node-pty")); | 92 | const nodePty = yield* Effect.promise(() => import("node-pty")); |
| 93 | |||
| 94 | const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( | ||
| 95 | ensureNodePtySpawnHelperExecutable().pipe( | ||
| 96 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 97 | Effect.provideService(Path.Path, path), | ||
| 98 | Effect.orElseSucceed(() => undefined), | ||
| 99 | ), | ||
| 100 | ); | ||
| 101 | |||
| 97 | return { | 102 | return { |
| 98 | spawn(input: PtySpawnInput): PtyProcess { | 103 | spawn: Effect.fn(function* (input) { |
| 99 | ensureNodePtySpawnHelperExecutable(); | 104 | yield* ensureNodePtySpawnHelperExecutableCached; |
| 100 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | 105 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { |
| 101 | cwd: input.cwd, | 106 | cwd: input.cwd, |
| 102 | cols: input.cols, | 107 | cols: input.cols, |
| ... | @@ -105,7 +110,7 @@ export const NodePtyAdapterLive = Layer.effect( | ... | @@ -105,7 +110,7 @@ export const NodePtyAdapterLive = Layer.effect( |
| 105 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | 110 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", |
| 106 | }); | 111 | }); |
| 107 | return new NodePtyProcess(ptyProcess); | 112 | return new NodePtyProcess(ptyProcess); |
| 108 | }, | 113 | }), |
| 109 | }; | 114 | } satisfies PtyAdapterShape; |
| 110 | }), | 115 | }), |
| 111 | ); | 116 | ); |
apps/server/src/terminal/Services/Manager.ts+17-8| ... | @@ -17,7 +17,12 @@ import { | ... | @@ -17,7 +17,12 @@ import { |
| 17 | TerminalWriteInput, | 17 | TerminalWriteInput, |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import { PtyProcess } from "./PTY"; | 19 | import { PtyProcess } from "./PTY"; |
| 20 | import { Effect, ServiceMap } from "effect"; | 20 | import { Effect, Schema, ServiceMap } from "effect"; |
| 21 | |||
| 22 | export class TerminalError extends Schema.TaggedErrorClass<TerminalError>()("TerminalError", { | ||
| 23 | message: Schema.String, | ||
| 24 | cause: Schema.optional(Schema.Defect), | ||
| 25 | }) {} | ||
| 21 | 26 | ||
| 22 | export interface TerminalSessionState { | 27 | export interface TerminalSessionState { |
| 23 | threadId: string; | 28 | threadId: string; |
| ... | @@ -58,36 +63,40 @@ export interface TerminalManagerShape { | ... | @@ -58,36 +63,40 @@ export interface TerminalManagerShape { |
| 58 | * Reuses an existing session for the same thread/terminal id and restores | 63 | * Reuses an existing session for the same thread/terminal id and restores |
| 59 | * persisted history on first open. | 64 | * persisted history on first open. |
| 60 | */ | 65 | */ |
| 61 | readonly open: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | 66 | readonly open: ( |
| 67 | input: TerminalOpenInput, | ||
| 68 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | ||
| 62 | 69 | ||
| 63 | /** | 70 | /** |
| 64 | * Write input bytes to a terminal session. | 71 | * Write input bytes to a terminal session. |
| 65 | */ | 72 | */ |
| 66 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, unknown>; | 73 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>; |
| 67 | 74 | ||
| 68 | /** | 75 | /** |
| 69 | * Resize the PTY backing a terminal session. | 76 | * Resize the PTY backing a terminal session. |
| 70 | */ | 77 | */ |
| 71 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, unknown>; | 78 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>; |
| 72 | 79 | ||
| 73 | /** | 80 | /** |
| 74 | * Clear terminal output history. | 81 | * Clear terminal output history. |
| 75 | */ | 82 | */ |
| 76 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, unknown>; | 83 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>; |
| 77 | 84 | ||
| 78 | /** | 85 | /** |
| 79 | * Restart a terminal session in place. | 86 | * Restart a terminal session in place. |
| 80 | * | 87 | * |
| 81 | * Always resets history before spawning the new process. | 88 | * Always resets history before spawning the new process. |
| 82 | */ | 89 | */ |
| 83 | readonly restart: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | 90 | readonly restart: ( |
| 91 | input: TerminalOpenInput, | ||
| 92 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | ||
| 84 | 93 | ||
| 85 | /** | 94 | /** |
| 86 | * Close an active terminal session. | 95 | * Close an active terminal session. |
| 87 | * | 96 | * |
| 88 | * When `terminalId` is omitted, closes all sessions for the thread. | 97 | * When `terminalId` is omitted, closes all sessions for the thread. |
| 89 | */ | 98 | */ |
| 90 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, unknown>; | 99 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>; |
| 91 | 100 | ||
| 92 | /** | 101 | /** |
| 93 | * Subscribe to terminal runtime events. | 102 | * Subscribe to terminal runtime events. |
| ... | @@ -97,7 +106,7 @@ export interface TerminalManagerShape { | ... | @@ -97,7 +106,7 @@ export interface TerminalManagerShape { |
| 97 | /** | 106 | /** |
| 98 | * Dispose all managed terminal resources. | 107 | * Dispose all managed terminal resources. |
| 99 | */ | 108 | */ |
| 100 | readonly dispose: () => Effect.Effect<void>; | 109 | readonly dispose: Effect.Effect<void>; |
| 101 | } | 110 | } |
| 102 | 111 | ||
| 103 | /** | 112 | /** |
apps/server/src/terminal/Services/PTY.ts+2-2| ... | @@ -6,7 +6,7 @@ | ... | @@ -6,7 +6,7 @@ |
| 6 | * | 6 | * |
| 7 | * @module PtyAdapter | 7 | * @module PtyAdapter |
| 8 | */ | 8 | */ |
| 9 | import { ServiceMap } from "effect"; | 9 | import { Effect, ServiceMap } from "effect"; |
| 10 | 10 | ||
| 11 | export interface PtyExitEvent { | 11 | export interface PtyExitEvent { |
| 12 | exitCode: number; | 12 | exitCode: number; |
| ... | @@ -38,7 +38,7 @@ export interface PtyAdapterShape { | ... | @@ -38,7 +38,7 @@ export interface PtyAdapterShape { |
| 38 | /** | 38 | /** |
| 39 | * Spawn a PTY process for a terminal session. | 39 | * Spawn a PTY process for a terminal session. |
| 40 | */ | 40 | */ |
| 41 | spawn(input: PtySpawnInput): PtyProcess; | 41 | spawn(input: PtySpawnInput): Effect.Effect<PtyProcess>; |
| 42 | } | 42 | } |
| 43 | 43 | ||
| 44 | /** | 44 | /** |