| author | |
| committer | |
| log | 4e8ee13d8f54e9aa84f1b15f9d802c30650217ea |
| tree | fd2a23c9ce24170fc4d2c53b517b37b6f3017066 |
| parent | b17b6d50c55e3eff4317163d31c4032423d3d2c1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>19 files changed, 4586 insertions(+), 4642 deletions(-)
apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts+2-2| ... | @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; |
| 6 | import * as Stream from "effect/Stream"; | 6 | import * as Stream from "effect/Stream"; |
| 7 | 7 | ||
| 8 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 8 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 9 | import { TerminalManager } from "../../terminal/Services/Manager.ts"; | 9 | import * as TerminalManager from "../../terminal/Manager.ts"; |
| 10 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 10 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 11 | import { | 11 | import { |
| 12 | ThreadDeletionReactor, | 12 | ThreadDeletionReactor, |
| ... | @@ -39,7 +39,7 @@ export const logCleanupCauseUnlessInterrupted = <R, E>({ | ... | @@ -39,7 +39,7 @@ export const logCleanupCauseUnlessInterrupted = <R, E>({ |
| 39 | const make = Effect.gen(function* () { | 39 | const make = Effect.gen(function* () { |
| 40 | const orchestrationEngine = yield* OrchestrationEngineService; | 40 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 41 | const providerService = yield* ProviderService; | 41 | const providerService = yield* ProviderService; |
| 42 | const terminalManager = yield* TerminalManager; | 42 | const terminalManager = yield* TerminalManager.TerminalManager; |
| 43 | 43 | ||
| 44 | const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => | 44 | const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => |
| 45 | logCleanupCauseUnlessInterrupted({ | 45 | logCleanupCauseUnlessInterrupted({ |
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+3-3| ... | @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; | ... | @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; |
| 5 | import { describe, expect, it, vi } from "vite-plus/test"; | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 6 | ||
| 7 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | 7 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 8 | import { TerminalManager } from "../../terminal/Services/Manager.ts"; | 8 | import * as TerminalManager from "../../terminal/Manager.ts"; |
| 9 | import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; | 9 | import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; |
| 10 | import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; | 10 | import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; |
| 11 | 11 | ||
| ... | @@ -52,7 +52,7 @@ describe("ProjectSetupScriptRunner", () => { | ... | @@ -52,7 +52,7 @@ describe("ProjectSetupScriptRunner", () => { |
| 52 | ProjectSetupScriptRunnerLive.pipe( | 52 | ProjectSetupScriptRunnerLive.pipe( |
| 53 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), | 53 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), |
| 54 | Layer.provideMerge( | 54 | Layer.provideMerge( |
| 55 | Layer.succeed(TerminalManager, { | 55 | Layer.succeed(TerminalManager.TerminalManager, { |
| 56 | open, | 56 | open, |
| 57 | attachStream: () => Effect.die(new Error("unused")), | 57 | attachStream: () => Effect.die(new Error("unused")), |
| 58 | write, | 58 | write, |
| ... | @@ -114,7 +114,7 @@ describe("ProjectSetupScriptRunner", () => { | ... | @@ -114,7 +114,7 @@ describe("ProjectSetupScriptRunner", () => { |
| 114 | ProjectSetupScriptRunnerLive.pipe( | 114 | ProjectSetupScriptRunnerLive.pipe( |
| 115 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), | 115 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), |
| 116 | Layer.provideMerge( | 116 | Layer.provideMerge( |
| 117 | Layer.succeed(TerminalManager, { | 117 | Layer.succeed(TerminalManager.TerminalManager, { |
| 118 | open, | 118 | open, |
| 119 | attachStream: () => Effect.die(new Error("unused")), | 119 | attachStream: () => Effect.die(new Error("unused")), |
| 120 | write, | 120 | write, |
apps/server/src/project/Layers/ProjectSetupScriptRunner.ts+2-2| ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | ||
| 7 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | 7 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 8 | import { TerminalManager } from "../../terminal/Services/Manager.ts"; | 8 | import * as TerminalManager from "../../terminal/Manager.ts"; |
| 9 | import { | 9 | import { |
| 10 | type ProjectSetupScriptRunnerShape, | 10 | type ProjectSetupScriptRunnerShape, |
| 11 | ProjectSetupScriptRunner, | 11 | ProjectSetupScriptRunner, |
| ... | @@ -14,7 +14,7 @@ import { | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | ||
| 15 | const makeProjectSetupScriptRunner = Effect.gen(function* () { | 15 | const makeProjectSetupScriptRunner = Effect.gen(function* () { |
| 16 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 16 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 17 | const terminalManager = yield* TerminalManager; | 17 | const terminalManager = yield* TerminalManager.TerminalManager; |
| 18 | 18 | ||
| 19 | const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => | 19 | const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => |
| 20 | Effect.gen(function* () { | 20 | Effect.gen(function* () { |
apps/server/src/server.test.ts+1-1| ... | @@ -85,7 +85,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid | ... | @@ -85,7 +85,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid |
| 85 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 85 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 86 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 86 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 87 | import * as ServerSettings from "./serverSettings.ts"; | 87 | import * as ServerSettings from "./serverSettings.ts"; |
| 88 | import * as TerminalManager from "./terminal/Services/Manager.ts"; | 88 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 89 | import * as PreviewManager from "./preview/Manager.ts"; | 89 | import * as PreviewManager from "./preview/Manager.ts"; |
| 90 | import * as PortScanner from "./preview/PortScanner.ts"; | 90 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 91 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; | 91 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
apps/server/src/server.ts+6-6| ... | @@ -33,7 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | ... | @@ -33,7 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| 33 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | 33 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 34 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 34 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 35 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | 35 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 36 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; | 36 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 37 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; | 37 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; |
| 38 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; | 38 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; |
| 39 | import * as PreviewManager from "./preview/Manager.ts"; | 39 | import * as PreviewManager from "./preview/Manager.ts"; |
| ... | @@ -101,11 +101,11 @@ const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; | ... | @@ -101,11 +101,11 @@ const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; |
| 101 | const PtyAdapterLive = Layer.unwrap( | 101 | const PtyAdapterLive = Layer.unwrap( |
| 102 | Effect.gen(function* () { | 102 | Effect.gen(function* () { |
| 103 | if (typeof Bun !== "undefined") { | 103 | if (typeof Bun !== "undefined") { |
| 104 | const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); | 104 | const BunPtyAdapter = yield* Effect.promise(() => import("./terminal/BunPtyAdapter.ts")); |
| 105 | return BunPTY.layer; | 105 | return BunPtyAdapter.layer; |
| 106 | } else { | 106 | } else { |
| 107 | const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); | 107 | const NodePtyAdapter = yield* Effect.promise(() => import("./terminal/NodePtyAdapter.ts")); |
| 108 | return NodePTY.layer; | 108 | return NodePtyAdapter.layer; |
| 109 | } | 109 | } |
| 110 | }), | 110 | }), |
| 111 | ); | 111 | ); |
| ... | @@ -238,7 +238,7 @@ const CheckpointingLayerLive = Layer.empty.pipe( | ... | @@ -238,7 +238,7 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 238 | 238 | ||
| 239 | const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); | 239 | const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); |
| 240 | 240 | ||
| 241 | const TerminalLayerLive = TerminalManagerLive.pipe( | 241 | const TerminalLayerLive = TerminalManager.layer.pipe( |
| 242 | Layer.provide(PtyAdapterLive), | 242 | Layer.provide(PtyAdapterLive), |
| 243 | Layer.provide(PortScannerLayerLive), | 243 | Layer.provide(PortScannerLayerLive), |
| 244 | ); | 244 | ); |
apps/server/src/terminal/BunPtyAdapter.ts created+133| ... | @@ -0,0 +1,133 @@ | ||
| 1 | /// <reference types="bun" /> | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 6 | |||
| 7 | import * as PtyAdapter from "./PtyAdapter.ts"; | ||
| 8 | |||
| 9 | class BunPtyProcess implements PtyAdapter.PtyProcess { | ||
| 10 | private readonly dataListeners = new Set<(data: string) => void>(); | ||
| 11 | private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); | ||
| 12 | private readonly decoder = new TextDecoder(); | ||
| 13 | private readonly process: Bun.Subprocess; | ||
| 14 | private didExit = false; | ||
| 15 | |||
| 16 | constructor(process: Bun.Subprocess) { | ||
| 17 | this.process = process; | ||
| 18 | void this.process.exited | ||
| 19 | .then((exitCode) => { | ||
| 20 | this.emitExit({ | ||
| 21 | exitCode: Number.isInteger(exitCode) ? exitCode : 0, | ||
| 22 | signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, | ||
| 23 | }); | ||
| 24 | }) | ||
| 25 | .catch(() => { | ||
| 26 | this.emitExit({ exitCode: 1, signal: null }); | ||
| 27 | }); | ||
| 28 | } | ||
| 29 | |||
| 30 | get pid(): number { | ||
| 31 | return this.process.pid; | ||
| 32 | } | ||
| 33 | |||
| 34 | write(data: string): void { | ||
| 35 | if (!this.process.terminal) { | ||
| 36 | throw new Error("Bun PTY terminal handle is unavailable"); | ||
| 37 | } | ||
| 38 | this.process.terminal.write(data); | ||
| 39 | } | ||
| 40 | |||
| 41 | resize(cols: number, rows: number): void { | ||
| 42 | if (!this.process.terminal?.resize) { | ||
| 43 | throw new Error("Bun PTY resize is unavailable"); | ||
| 44 | } | ||
| 45 | this.process.terminal.resize(cols, rows); | ||
| 46 | } | ||
| 47 | |||
| 48 | kill(signal?: string): void { | ||
| 49 | if (!signal) { | ||
| 50 | this.process.kill(); | ||
| 51 | return; | ||
| 52 | } | ||
| 53 | this.process.kill(signal as NodeJS.Signals); | ||
| 54 | } | ||
| 55 | |||
| 56 | onData(callback: (data: string) => void): () => void { | ||
| 57 | this.dataListeners.add(callback); | ||
| 58 | return () => { | ||
| 59 | this.dataListeners.delete(callback); | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { | ||
| 64 | this.exitListeners.add(callback); | ||
| 65 | return () => { | ||
| 66 | this.exitListeners.delete(callback); | ||
| 67 | }; | ||
| 68 | } | ||
| 69 | |||
| 70 | emitData(data: Uint8Array): void { | ||
| 71 | if (this.didExit) return; | ||
| 72 | const text = this.decoder.decode(data, { stream: true }); | ||
| 73 | if (text.length === 0) return; | ||
| 74 | for (const listener of this.dataListeners) { | ||
| 75 | listener(text); | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | private emitExit(event: PtyAdapter.PtyExitEvent): void { | ||
| 80 | if (this.didExit) return; | ||
| 81 | this.didExit = true; | ||
| 82 | |||
| 83 | const remainder = this.decoder.decode(); | ||
| 84 | if (remainder.length > 0) { | ||
| 85 | for (const listener of this.dataListeners) { | ||
| 86 | listener(remainder); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | for (const listener of this.exitListeners) { | ||
| 91 | listener(event); | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | export const make = Effect.fn("BunPtyAdapter.make")(function* () { | ||
| 97 | const platform = yield* HostProcessPlatform; | ||
| 98 | if (platform === "win32") { | ||
| 99 | return yield* Effect.die( | ||
| 100 | "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | return PtyAdapter.PtyAdapter.of({ | ||
| 104 | spawn: (input) => | ||
| 105 | Effect.try({ | ||
| 106 | try: () => { | ||
| 107 | let processHandle: BunPtyProcess | null = null; | ||
| 108 | const command = [input.shell, ...(input.args ?? [])]; | ||
| 109 | const subprocess = Bun.spawn(command, { | ||
| 110 | cwd: input.cwd, | ||
| 111 | env: input.env, | ||
| 112 | terminal: { | ||
| 113 | cols: input.cols, | ||
| 114 | rows: input.rows, | ||
| 115 | data: (_terminal, data) => { | ||
| 116 | processHandle?.emitData(data); | ||
| 117 | }, | ||
| 118 | }, | ||
| 119 | }); | ||
| 120 | processHandle = new BunPtyProcess(subprocess); | ||
| 121 | return processHandle; | ||
| 122 | }, | ||
| 123 | catch: (cause) => | ||
| 124 | new PtyAdapter.PtySpawnError({ | ||
| 125 | adapter: "bun", | ||
| 126 | shell: input.shell, | ||
| 127 | cause, | ||
| 128 | }), | ||
| 129 | }), | ||
| 130 | }); | ||
| 131 | }); | ||
| 132 | |||
| 133 | export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); | ||
apps/server/src/terminal/Layers/BunPTY.ts deleted-126| ... | @@ -1,126 +0,0 @@ | ||
| 1 | /// <reference types="bun" /> | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 6 | import { PtyAdapter } from "../Services/PTY.ts"; | ||
| 7 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; | ||
| 8 | |||
| 9 | class BunPtyProcess implements PtyProcess { | ||
| 10 | private readonly dataListeners = new Set<(data: string) => void>(); | ||
| 11 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | ||
| 12 | private readonly decoder = new TextDecoder(); | ||
| 13 | private readonly process: Bun.Subprocess; | ||
| 14 | private didExit = false; | ||
| 15 | |||
| 16 | constructor(process: Bun.Subprocess) { | ||
| 17 | this.process = process; | ||
| 18 | void this.process.exited | ||
| 19 | .then((exitCode) => { | ||
| 20 | this.emitExit({ | ||
| 21 | exitCode: Number.isInteger(exitCode) ? exitCode : 0, | ||
| 22 | signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, | ||
| 23 | }); | ||
| 24 | }) | ||
| 25 | .catch(() => { | ||
| 26 | this.emitExit({ exitCode: 1, signal: null }); | ||
| 27 | }); | ||
| 28 | } | ||
| 29 | |||
| 30 | get pid(): number { | ||
| 31 | return this.process.pid; | ||
| 32 | } | ||
| 33 | |||
| 34 | write(data: string): void { | ||
| 35 | if (!this.process.terminal) { | ||
| 36 | throw new Error("Bun PTY terminal handle is unavailable"); | ||
| 37 | } | ||
| 38 | this.process.terminal.write(data); | ||
| 39 | } | ||
| 40 | |||
| 41 | resize(cols: number, rows: number): void { | ||
| 42 | if (!this.process.terminal?.resize) { | ||
| 43 | throw new Error("Bun PTY resize is unavailable"); | ||
| 44 | } | ||
| 45 | this.process.terminal.resize(cols, rows); | ||
| 46 | } | ||
| 47 | |||
| 48 | kill(signal?: string): void { | ||
| 49 | if (!signal) { | ||
| 50 | this.process.kill(); | ||
| 51 | return; | ||
| 52 | } | ||
| 53 | this.process.kill(signal as NodeJS.Signals); | ||
| 54 | } | ||
| 55 | |||
| 56 | onData(callback: (data: string) => void): () => void { | ||
| 57 | this.dataListeners.add(callback); | ||
| 58 | return () => { | ||
| 59 | this.dataListeners.delete(callback); | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | onExit(callback: (event: PtyExitEvent) => void): () => void { | ||
| 64 | this.exitListeners.add(callback); | ||
| 65 | return () => { | ||
| 66 | this.exitListeners.delete(callback); | ||
| 67 | }; | ||
| 68 | } | ||
| 69 | |||
| 70 | emitData(data: Uint8Array): void { | ||
| 71 | if (this.didExit) return; | ||
| 72 | const text = this.decoder.decode(data, { stream: true }); | ||
| 73 | if (text.length === 0) return; | ||
| 74 | for (const listener of this.dataListeners) { | ||
| 75 | listener(text); | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | private emitExit(event: PtyExitEvent): void { | ||
| 80 | if (this.didExit) return; | ||
| 81 | this.didExit = true; | ||
| 82 | |||
| 83 | const remainder = this.decoder.decode(); | ||
| 84 | if (remainder.length > 0) { | ||
| 85 | for (const listener of this.dataListeners) { | ||
| 86 | listener(remainder); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | for (const listener of this.exitListeners) { | ||
| 91 | listener(event); | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | export const layer = Layer.effect( | ||
| 97 | PtyAdapter, | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const platform = yield* HostProcessPlatform; | ||
| 100 | if (platform === "win32") { | ||
| 101 | return yield* Effect.die( | ||
| 102 | "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", | ||
| 103 | ); | ||
| 104 | } | ||
| 105 | return { | ||
| 106 | spawn: (input) => | ||
| 107 | Effect.sync(() => { | ||
| 108 | let processHandle: BunPtyProcess | null = null; | ||
| 109 | const command = [input.shell, ...(input.args ?? [])]; | ||
| 110 | const subprocess = Bun.spawn(command, { | ||
| 111 | cwd: input.cwd, | ||
| 112 | env: input.env, | ||
| 113 | terminal: { | ||
| 114 | cols: input.cols, | ||
| 115 | rows: input.rows, | ||
| 116 | data: (_terminal, data) => { | ||
| 117 | processHandle?.emitData(data); | ||
| 118 | }, | ||
| 119 | }, | ||
| 120 | }); | ||
| 121 | processHandle = new BunPtyProcess(subprocess); | ||
| 122 | return processHandle; | ||
| 123 | }), | ||
| 124 | } satisfies PtyAdapterShape; | ||
| 125 | }), | ||
| 126 | ); | ||
apps/server/src/terminal/Layers/Manager.test.ts deleted-1555| ... | @@ -1,1555 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | DEFAULT_TERMINAL_ID, | ||
| 5 | type TerminalAttachStreamEvent, | ||
| 6 | type TerminalEvent, | ||
| 7 | type TerminalMetadataStreamEvent, | ||
| 8 | type TerminalOpenInput, | ||
| 9 | type TerminalRestartInput, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 12 | import * as Data from "effect/Data"; | ||
| 13 | import * as Duration from "effect/Duration"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Encoding from "effect/Encoding"; | ||
| 16 | import * as Exit from "effect/Exit"; | ||
| 17 | import * as Fiber from "effect/Fiber"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Layer from "effect/Layer"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import * as PlatformError from "effect/PlatformError"; | ||
| 22 | import * as Path from "effect/Path"; | ||
| 23 | import * as Ref from "effect/Ref"; | ||
| 24 | import * as Schedule from "effect/Schedule"; | ||
| 25 | import * as Scope from "effect/Scope"; | ||
| 26 | import { TestClock } from "effect/testing"; | ||
| 27 | import { expect } from "vite-plus/test"; | ||
| 28 | |||
| 29 | import * as ProcessRunner from "../../processRunner.ts"; | ||
| 30 | import type { TerminalManagerShape } from "../Services/Manager.ts"; | ||
| 31 | import { | ||
| 32 | type PtyAdapterShape, | ||
| 33 | type PtyExitEvent, | ||
| 34 | type PtyProcess, | ||
| 35 | type PtySpawnInput, | ||
| 36 | PtySpawnError, | ||
| 37 | } from "../Services/PTY.ts"; | ||
| 38 | import { makeTerminalManagerWithOptions } from "./Manager.ts"; | ||
| 39 | |||
| 40 | class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ | ||
| 41 | readonly message: string; | ||
| 42 | }> {} | ||
| 43 | |||
| 44 | class FakePtyProcess implements PtyProcess { | ||
| 45 | readonly writes: string[] = []; | ||
| 46 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; | ||
| 47 | readonly killSignals: Array<string | undefined> = []; | ||
| 48 | readonly pid: number; | ||
| 49 | private readonly dataListeners = new Set<(data: string) => void>(); | ||
| 50 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | ||
| 51 | killed = false; | ||
| 52 | |||
| 53 | constructor(pid: number) { | ||
| 54 | this.pid = pid; | ||
| 55 | } | ||
| 56 | |||
| 57 | write(data: string): void { | ||
| 58 | this.writes.push(data); | ||
| 59 | } | ||
| 60 | |||
| 61 | resize(cols: number, rows: number): void { | ||
| 62 | this.resizeCalls.push({ cols, rows }); | ||
| 63 | } | ||
| 64 | |||
| 65 | kill(signal?: string): void { | ||
| 66 | this.killed = true; | ||
| 67 | this.killSignals.push(signal); | ||
| 68 | } | ||
| 69 | |||
| 70 | onData(callback: (data: string) => void): () => void { | ||
| 71 | this.dataListeners.add(callback); | ||
| 72 | return () => { | ||
| 73 | this.dataListeners.delete(callback); | ||
| 74 | }; | ||
| 75 | } | ||
| 76 | |||
| 77 | onExit(callback: (event: PtyExitEvent) => void): () => void { | ||
| 78 | this.exitListeners.add(callback); | ||
| 79 | return () => { | ||
| 80 | this.exitListeners.delete(callback); | ||
| 81 | }; | ||
| 82 | } | ||
| 83 | |||
| 84 | emitData(data: string): void { | ||
| 85 | for (const listener of this.dataListeners) { | ||
| 86 | listener(data); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | emitExit(event: PtyExitEvent): void { | ||
| 91 | for (const listener of this.exitListeners) { | ||
| 92 | listener(event); | ||
| 93 | } | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | class FakePtyAdapter implements PtyAdapterShape { | ||
| 98 | readonly spawnInputs: PtySpawnInput[] = []; | ||
| 99 | readonly processes: FakePtyProcess[] = []; | ||
| 100 | readonly spawnFailures: Error[] = []; | ||
| 101 | private readonly mode: "sync" | "async"; | ||
| 102 | private nextPid = 9000; | ||
| 103 | |||
| 104 | constructor(mode: "sync" | "async" = "sync") { | ||
| 105 | this.mode = mode; | ||
| 106 | } | ||
| 107 | |||
| 108 | spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError> { | ||
| 109 | this.spawnInputs.push(input); | ||
| 110 | const failure = this.spawnFailures.shift(); | ||
| 111 | if (failure) { | ||
| 112 | return Effect.fail( | ||
| 113 | new PtySpawnError({ | ||
| 114 | adapter: "fake", | ||
| 115 | message: "Failed to spawn PTY process", | ||
| 116 | cause: failure, | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | const process = new FakePtyProcess(this.nextPid++); | ||
| 121 | this.processes.push(process); | ||
| 122 | if (this.mode === "async") { | ||
| 123 | return Effect.tryPromise({ | ||
| 124 | try: async () => process, | ||
| 125 | catch: (cause) => | ||
| 126 | new PtySpawnError({ | ||
| 127 | adapter: "fake", | ||
| 128 | message: "Failed to spawn PTY process", | ||
| 129 | cause, | ||
| 130 | }), | ||
| 131 | }); | ||
| 132 | } | ||
| 133 | return Effect.succeed(process); | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | const waitFor = <E, R>( | ||
| 138 | predicate: Effect.Effect<boolean, E, R>, | ||
| 139 | timeout: Duration.Input = 800, | ||
| 140 | ): Effect.Effect<void, WaitForConditionError | E, R> => | ||
| 141 | predicate.pipe( | ||
| 142 | Effect.filterOrFail( | ||
| 143 | (done) => done, | ||
| 144 | () => new WaitForConditionError({ message: "Condition not met" }), | ||
| 145 | ), | ||
| 146 | Effect.retry(Schedule.spaced("15 millis")), | ||
| 147 | Effect.timeoutOption(timeout), | ||
| 148 | Effect.flatMap((result) => | ||
| 149 | Option.match(result, { | ||
| 150 | onNone: () => | ||
| 151 | Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), | ||
| 152 | onSome: () => Effect.void, | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | ); | ||
| 156 | |||
| 157 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { | ||
| 158 | return { | ||
| 159 | threadId: "thread-1", | ||
| 160 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 161 | cwd: process.cwd(), | ||
| 162 | cols: 100, | ||
| 163 | rows: 24, | ||
| 164 | ...overrides, | ||
| 165 | }; | ||
| 166 | } | ||
| 167 | |||
| 168 | function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput { | ||
| 169 | return { | ||
| 170 | threadId: "thread-1", | ||
| 171 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 172 | cwd: process.cwd(), | ||
| 173 | cols: 100, | ||
| 174 | rows: 24, | ||
| 175 | ...overrides, | ||
| 176 | }; | ||
| 177 | } | ||
| 178 | |||
| 179 | const historyLogPath = (logsDir: string, threadId = "thread-1") => | ||
| 180 | Effect.service(Path.Path).pipe( | ||
| 181 | Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), | ||
| 182 | ); | ||
| 183 | |||
| 184 | const multiTerminalHistoryLogPath = ( | ||
| 185 | logsDir: string, | ||
| 186 | threadId = "thread-1", | ||
| 187 | terminalId = DEFAULT_TERMINAL_ID, | ||
| 188 | ) => | ||
| 189 | Effect.service(Path.Path).pipe( | ||
| 190 | Effect.map(({ join }) => { | ||
| 191 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | ||
| 192 | return join( | ||
| 193 | logsDir, | ||
| 194 | terminalId === DEFAULT_TERMINAL_ID | ||
| 195 | ? `${threadPart}.log` | ||
| 196 | : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, | ||
| 197 | ); | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | |||
| 201 | interface CreateManagerOptions { | ||
| 202 | shellResolver?: () => string; | ||
| 203 | env?: NodeJS.ProcessEnv; | ||
| 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ | ||
| 205 | readonly hasRunningSubprocess: boolean; | ||
| 206 | readonly childCommand: string | null; | ||
| 207 | readonly processIds: ReadonlyArray<number>; | ||
| 208 | }>; | ||
| 209 | subprocessPollIntervalMs?: number; | ||
| 210 | processKillGraceMs?: number; | ||
| 211 | maxRetainedInactiveSessions?: number; | ||
| 212 | ptyAdapter?: FakePtyAdapter; | ||
| 213 | } | ||
| 214 | |||
| 215 | interface ManagerFixture { | ||
| 216 | readonly baseDir: string; | ||
| 217 | readonly logsDir: string; | ||
| 218 | readonly ptyAdapter: FakePtyAdapter; | ||
| 219 | readonly manager: TerminalManagerShape; | ||
| 220 | readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>; | ||
| 221 | } | ||
| 222 | |||
| 223 | const createManager = ( | ||
| 224 | historyLineLimit = 5, | ||
| 225 | options: CreateManagerOptions = {}, | ||
| 226 | ): Effect.Effect< | ||
| 227 | ManagerFixture, | ||
| 228 | PlatformError.PlatformError, | ||
| 229 | FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner | ||
| 230 | > => | ||
| 231 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const { join } = yield* Path.Path; | ||
| 234 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); | ||
| 235 | const logsDir = join(baseDir, "userdata", "logs", "terminals"); | ||
| 236 | const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); | ||
| 237 | |||
| 238 | const manager = yield* makeTerminalManagerWithOptions({ | ||
| 239 | logsDir, | ||
| 240 | historyLineLimit, | ||
| 241 | ptyAdapter, | ||
| 242 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), | ||
| 243 | ...(options.env !== undefined ? { env: options.env } : {}), | ||
| 244 | ...(options.subprocessInspector !== undefined | ||
| 245 | ? { subprocessInspector: options.subprocessInspector } | ||
| 246 | : {}), | ||
| 247 | ...(options.subprocessPollIntervalMs !== undefined | ||
| 248 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | ||
| 249 | : {}), | ||
| 250 | processKillGraceMs: options.processKillGraceMs ?? 1, | ||
| 251 | ...(options.maxRetainedInactiveSessions !== undefined | ||
| 252 | ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } | ||
| 253 | : {}), | ||
| 254 | }); | ||
| 255 | const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); | ||
| 256 | const unsubscribe = yield* manager.subscribe((event) => | ||
| 257 | Ref.update(eventsRef, (events) => [...events, event]), | ||
| 258 | ); | ||
| 259 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 260 | |||
| 261 | return { | ||
| 262 | baseDir, | ||
| 263 | logsDir, | ||
| 264 | join, | ||
| 265 | ptyAdapter, | ||
| 266 | manager, | ||
| 267 | getEvents: Ref.get(eventsRef), | ||
| 268 | }; | ||
| 269 | }), | ||
| 270 | ); | ||
| 271 | |||
| 272 | const withHostPlatform = (platform: NodeJS.Platform) => | ||
| 273 | Layer.succeed(HostProcessPlatform, platform); | ||
| 274 | |||
| 275 | it.layer( | ||
| 276 | Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), | ||
| 277 | { excludeTestServices: true }, | ||
| 278 | )("TerminalManager", (it) => { | ||
| 279 | it.effect("spawns lazily and reuses running terminal per thread", () => | ||
| 280 | Effect.gen(function* () { | ||
| 281 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 282 | const [first, second] = yield* Effect.all( | ||
| 283 | [manager.open(openInput()), manager.open(openInput())], | ||
| 284 | { concurrency: "unbounded" }, | ||
| 285 | ); | ||
| 286 | const third = yield* manager.open(openInput()); | ||
| 287 | |||
| 288 | assert.equal(first.threadId, "thread-1"); | ||
| 289 | assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); | ||
| 290 | assert.equal(second.threadId, "thread-1"); | ||
| 291 | assert.equal(third.threadId, "thread-1"); | ||
| 292 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 293 | }), | ||
| 294 | ); | ||
| 295 | |||
| 296 | it.effect("attaches to running sessions without restarting them", () => | ||
| 297 | Effect.gen(function* () { | ||
| 298 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 299 | |||
| 300 | yield* manager.open(openInput()); | ||
| 301 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 302 | const unsubscribe = yield* manager.attachStream( | ||
| 303 | { | ||
| 304 | threadId: "thread-1", | ||
| 305 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 306 | cols: 100, | ||
| 307 | rows: 40, | ||
| 308 | }, | ||
| 309 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 310 | ); | ||
| 311 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 312 | |||
| 313 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 314 | expect(snapshot).toBeDefined(); | ||
| 315 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 316 | assert.equal(snapshot.snapshot.threadId, "thread-1"); | ||
| 317 | assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); | ||
| 318 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | |||
| 322 | it.effect("keeps attach streams live when a terminal id is closed and reopened", () => | ||
| 323 | Effect.gen(function* () { | ||
| 324 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 325 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 326 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 327 | Ref.update(attachEvents, (events) => [...events, event]), | ||
| 328 | ); | ||
| 329 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 330 | |||
| 331 | yield* manager.close({ | ||
| 332 | threadId: "thread-1", | ||
| 333 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 334 | deleteHistory: true, | ||
| 335 | }); | ||
| 336 | yield* manager.open(openInput()); | ||
| 337 | |||
| 338 | const events = yield* Ref.get(attachEvents); | ||
| 339 | expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); | ||
| 340 | expect( | ||
| 341 | events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), | ||
| 342 | ).toEqual(["running", "running"]); | ||
| 343 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 344 | }), | ||
| 345 | ); | ||
| 346 | |||
| 347 | it.effect("attaches to exited sessions without restarting them", () => | ||
| 348 | Effect.gen(function* () { | ||
| 349 | const { manager, ptyAdapter, getEvents } = yield* createManager(); | ||
| 350 | |||
| 351 | yield* manager.open(openInput()); | ||
| 352 | const process = ptyAdapter.processes[0]; | ||
| 353 | expect(process).toBeDefined(); | ||
| 354 | if (!process) return; | ||
| 355 | |||
| 356 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 357 | |||
| 358 | yield* waitFor( | ||
| 359 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 360 | "1200 millis", | ||
| 361 | ); | ||
| 362 | |||
| 363 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 364 | const unsubscribe = yield* manager.attachStream( | ||
| 365 | openInput({ | ||
| 366 | env: { | ||
| 367 | T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", | ||
| 368 | }, | ||
| 369 | worktreePath: "/tmp/should-not-restart", | ||
| 370 | }), | ||
| 371 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 372 | ); | ||
| 373 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 374 | |||
| 375 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 376 | expect(snapshot).toBeDefined(); | ||
| 377 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 378 | assert.equal(snapshot.snapshot.status, "exited"); | ||
| 379 | assert.equal(snapshot.snapshot.worktreePath, null); | ||
| 380 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 381 | }), | ||
| 382 | ); | ||
| 383 | |||
| 384 | it.effect("restarts inactive sessions from attach only when requested", () => | ||
| 385 | Effect.gen(function* () { | ||
| 386 | const { manager, ptyAdapter, getEvents } = yield* createManager(); | ||
| 387 | |||
| 388 | yield* manager.open(openInput()); | ||
| 389 | const process = ptyAdapter.processes[0]; | ||
| 390 | expect(process).toBeDefined(); | ||
| 391 | if (!process) return; | ||
| 392 | |||
| 393 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 394 | |||
| 395 | yield* waitFor( | ||
| 396 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 397 | "1200 millis", | ||
| 398 | ); | ||
| 399 | |||
| 400 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 401 | const unsubscribe = yield* manager.attachStream( | ||
| 402 | { | ||
| 403 | ...openInput({ | ||
| 404 | env: { | ||
| 405 | T3CODE_WORKTREE_PATH: "/tmp/restart-requested", | ||
| 406 | }, | ||
| 407 | worktreePath: "/tmp/restart-requested", | ||
| 408 | }), | ||
| 409 | restartIfNotRunning: true, | ||
| 410 | }, | ||
| 411 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 412 | ); | ||
| 413 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 414 | |||
| 415 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 416 | expect(snapshot).toBeDefined(); | ||
| 417 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 418 | assert.equal(snapshot.snapshot.status, "running"); | ||
| 419 | assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); | ||
| 420 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 421 | }), | ||
| 422 | ); | ||
| 423 | |||
| 424 | const makeDirectory = (filePath: string) => | ||
| 425 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | ||
| 426 | fs.makeDirectory(filePath, { recursive: true }), | ||
| 427 | ); | ||
| 428 | |||
| 429 | const chmod = (filePath: string, mode: number) => | ||
| 430 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); | ||
| 431 | |||
| 432 | const pathExists = (filePath: string) => | ||
| 433 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); | ||
| 434 | |||
| 435 | const readFileString = (filePath: string) => | ||
| 436 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); | ||
| 437 | |||
| 438 | const writeFileString = (filePath: string, contents: string) => | ||
| 439 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | ||
| 440 | fs.writeFileString(filePath, contents), | ||
| 441 | ); | ||
| 442 | |||
| 443 | it.effect("preserves non-notFound cwd stat failures", () => | ||
| 444 | Effect.gen(function* () { | ||
| 445 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 446 | |||
| 447 | const path = yield* Path.Path; | ||
| 448 | |||
| 449 | const { manager, baseDir } = yield* createManager(); | ||
| 450 | const blockedRoot = path.join(baseDir, "blocked-root"); | ||
| 451 | const blockedCwd = path.join(blockedRoot, "cwd"); | ||
| 452 | yield* makeDirectory(blockedCwd); | ||
| 453 | yield* chmod(blockedRoot, 0o000); | ||
| 454 | |||
| 455 | const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( | ||
| 456 | Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), | ||
| 457 | ); | ||
| 458 | |||
| 459 | expect(error).toMatchObject({ | ||
| 460 | _tag: "TerminalCwdError", | ||
| 461 | cwd: blockedCwd, | ||
| 462 | reason: "statFailed", | ||
| 463 | }); | ||
| 464 | }), | ||
| 465 | ); | ||
| 466 | |||
| 467 | it.effect("supports asynchronous PTY spawn effects", () => | ||
| 468 | Effect.gen(function* () { | ||
| 469 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 470 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 471 | }); | ||
| 472 | |||
| 473 | const snapshot = yield* manager.open(openInput()); | ||
| 474 | |||
| 475 | assert.equal(snapshot.status, "running"); | ||
| 476 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 477 | expect(ptyAdapter.processes).toHaveLength(1); | ||
| 478 | }), | ||
| 479 | ); | ||
| 480 | |||
| 481 | it.effect("forwards write and resize to active pty process", () => | ||
| 482 | Effect.gen(function* () { | ||
| 483 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 484 | yield* manager.open(openInput()); | ||
| 485 | const process = ptyAdapter.processes[0]; | ||
| 486 | expect(process).toBeDefined(); | ||
| 487 | if (!process) return; | ||
| 488 | |||
| 489 | yield* manager.write({ | ||
| 490 | threadId: "thread-1", | ||
| 491 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 492 | data: "ls\n", | ||
| 493 | }); | ||
| 494 | yield* manager.resize({ | ||
| 495 | threadId: "thread-1", | ||
| 496 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 497 | cols: 120, | ||
| 498 | rows: 30, | ||
| 499 | }); | ||
| 500 | |||
| 501 | expect(process.writes).toEqual(["ls\n"]); | ||
| 502 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | ||
| 503 | }), | ||
| 504 | ); | ||
| 505 | |||
| 506 | it.effect("ignores delayed resize requests after a terminal closes", () => | ||
| 507 | Effect.gen(function* () { | ||
| 508 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 509 | yield* manager.open(openInput()); | ||
| 510 | const process = ptyAdapter.processes[0]; | ||
| 511 | expect(process).toBeDefined(); | ||
| 512 | if (!process) return; | ||
| 513 | |||
| 514 | yield* manager.close({ | ||
| 515 | threadId: "thread-1", | ||
| 516 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 517 | deleteHistory: true, | ||
| 518 | }); | ||
| 519 | yield* manager.resize({ | ||
| 520 | threadId: "thread-1", | ||
| 521 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 522 | cols: 120, | ||
| 523 | rows: 30, | ||
| 524 | }); | ||
| 525 | |||
| 526 | expect(process.resizeCalls).toEqual([]); | ||
| 527 | }), | ||
| 528 | ); | ||
| 529 | |||
| 530 | it.effect("resizes running terminal on open when a different size is requested", () => | ||
| 531 | Effect.gen(function* () { | ||
| 532 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 533 | yield* manager.open(openInput({ cols: 100, rows: 24 })); | ||
| 534 | const process = ptyAdapter.processes[0]; | ||
| 535 | expect(process).toBeDefined(); | ||
| 536 | if (!process) return; | ||
| 537 | |||
| 538 | const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); | ||
| 539 | |||
| 540 | assert.equal(reopened.status, "running"); | ||
| 541 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | ||
| 542 | }), | ||
| 543 | ); | ||
| 544 | |||
| 545 | it.effect("supports multiple terminals per thread independently", () => | ||
| 546 | Effect.gen(function* () { | ||
| 547 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 548 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 549 | yield* manager.open(openInput({ terminalId: "term-2" })); | ||
| 550 | |||
| 551 | const first = ptyAdapter.processes[0]; | ||
| 552 | const second = ptyAdapter.processes[1]; | ||
| 553 | expect(first).toBeDefined(); | ||
| 554 | expect(second).toBeDefined(); | ||
| 555 | if (!first || !second) return; | ||
| 556 | |||
| 557 | yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); | ||
| 558 | yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); | ||
| 559 | |||
| 560 | expect(first.writes).toEqual(["pwd\n"]); | ||
| 561 | expect(second.writes).toEqual(["ls\n"]); | ||
| 562 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 563 | }), | ||
| 564 | ); | ||
| 565 | |||
| 566 | it.effect("clears transcript and emits cleared event", () => | ||
| 567 | Effect.gen(function* () { | ||
| 568 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); | ||
| 569 | const path = yield* Path.Path; | ||
| 570 | yield* manager.open(openInput()); | ||
| 571 | const process = ptyAdapter.processes[0]; | ||
| 572 | expect(process).toBeDefined(); | ||
| 573 | if (!process) return; | ||
| 574 | |||
| 575 | process.emitData("hello\n"); | ||
| 576 | yield* waitFor( | ||
| 577 | historyLogPath(logsDir).pipe( | ||
| 578 | Effect.provideService(Path.Path, path), | ||
| 579 | Effect.flatMap(pathExists), | ||
| 580 | ), | ||
| 581 | ); | ||
| 582 | yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); | ||
| 583 | yield* waitFor( | ||
| 584 | historyLogPath(logsDir).pipe( | ||
| 585 | Effect.provideService(Path.Path, path), | ||
| 586 | Effect.flatMap(readFileString), | ||
| 587 | Effect.map((text) => text === ""), | ||
| 588 | ), | ||
| 589 | ); | ||
| 590 | |||
| 591 | const events = yield* getEvents; | ||
| 592 | expect(events.some((event) => event.type === "cleared")).toBe(true); | ||
| 593 | expect( | ||
| 594 | events.some( | ||
| 595 | (event) => | ||
| 596 | event.type === "cleared" && | ||
| 597 | event.threadId === "thread-1" && | ||
| 598 | event.terminalId === DEFAULT_TERMINAL_ID, | ||
| 599 | ), | ||
| 600 | ).toBe(true); | ||
| 601 | }), | ||
| 602 | ); | ||
| 603 | |||
| 604 | it.effect("restarts terminal with empty transcript and respawns pty", () => | ||
| 605 | Effect.gen(function* () { | ||
| 606 | const { manager, ptyAdapter, logsDir } = yield* createManager(); | ||
| 607 | yield* manager.open(openInput()); | ||
| 608 | const firstProcess = ptyAdapter.processes[0]; | ||
| 609 | expect(firstProcess).toBeDefined(); | ||
| 610 | if (!firstProcess) return; | ||
| 611 | firstProcess.emitData("before restart\n"); | ||
| 612 | const path = yield* Path.Path; | ||
| 613 | yield* waitFor( | ||
| 614 | historyLogPath(logsDir).pipe( | ||
| 615 | Effect.provideService(Path.Path, path), | ||
| 616 | Effect.flatMap(pathExists), | ||
| 617 | ), | ||
| 618 | ); | ||
| 619 | |||
| 620 | const snapshot = yield* manager.restart(restartInput()); | ||
| 621 | assert.equal(snapshot.history, ""); | ||
| 622 | assert.equal(snapshot.status, "running"); | ||
| 623 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 624 | yield* waitFor( | ||
| 625 | historyLogPath(logsDir).pipe( | ||
| 626 | Effect.provideService(Path.Path, path), | ||
| 627 | Effect.flatMap(readFileString), | ||
| 628 | Effect.map((text) => text === ""), | ||
| 629 | ), | ||
| 630 | ); | ||
| 631 | }), | ||
| 632 | ); | ||
| 633 | |||
| 634 | it.effect("restarts a running session when open is called with a different cwd", () => | ||
| 635 | Effect.gen(function* () { | ||
| 636 | const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); | ||
| 637 | const path = yield* Path.Path; | ||
| 638 | const originalCwd = path.join(baseDir, "original"); | ||
| 639 | const differentCwd = path.join(baseDir, "different"); | ||
| 640 | yield* makeDirectory(originalCwd); | ||
| 641 | yield* makeDirectory(differentCwd); | ||
| 642 | |||
| 643 | yield* manager.open(openInput({ cwd: originalCwd })); | ||
| 644 | const firstProcess = ptyAdapter.processes[0]; | ||
| 645 | expect(firstProcess).toBeDefined(); | ||
| 646 | if (!firstProcess) return; | ||
| 647 | |||
| 648 | firstProcess.emitData("before reopen\n"); | ||
| 649 | const logPath = yield* historyLogPath(logsDir); | ||
| 650 | yield* waitFor(pathExists(logPath)); | ||
| 651 | |||
| 652 | const reopened = yield* manager.open(openInput({ cwd: differentCwd })); | ||
| 653 | |||
| 654 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 655 | assert.equal(firstProcess.killed, true); | ||
| 656 | assert.equal(reopened.cwd, differentCwd); | ||
| 657 | assert.equal(reopened.history, ""); | ||
| 658 | yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); | ||
| 659 | }), | ||
| 660 | ); | ||
| 661 | |||
| 662 | it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => | ||
| 663 | Effect.gen(function* () { | ||
| 664 | const { manager, getEvents, baseDir } = yield* createManager(); | ||
| 665 | const path = yield* Path.Path; | ||
| 666 | const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); | ||
| 667 | const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); | ||
| 668 | yield* makeDirectory(firstWorktreePath); | ||
| 669 | yield* makeDirectory(secondWorktreePath); | ||
| 670 | const startedSnapshot = yield* manager.open( | ||
| 671 | openInput({ | ||
| 672 | cwd: firstWorktreePath, | ||
| 673 | worktreePath: firstWorktreePath, | ||
| 674 | }), | ||
| 675 | ); | ||
| 676 | const restartedSnapshot = yield* manager.restart( | ||
| 677 | restartInput({ | ||
| 678 | cwd: secondWorktreePath, | ||
| 679 | worktreePath: secondWorktreePath, | ||
| 680 | }), | ||
| 681 | ); | ||
| 682 | |||
| 683 | assert.equal(startedSnapshot.worktreePath, firstWorktreePath); | ||
| 684 | assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); | ||
| 685 | |||
| 686 | const events = yield* getEvents; | ||
| 687 | const startedEvent = events.find( | ||
| 688 | (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started", | ||
| 689 | ); | ||
| 690 | const restartedEvent = events.find( | ||
| 691 | (event): event is Extract<TerminalEvent, { type: "restarted" }> => | ||
| 692 | event.type === "restarted", | ||
| 693 | ); | ||
| 694 | |||
| 695 | assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); | ||
| 696 | assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); | ||
| 697 | }), | ||
| 698 | ); | ||
| 699 | |||
| 700 | it.effect("preserves worktree metadata when reopening an exited session", () => | ||
| 701 | Effect.gen(function* () { | ||
| 702 | const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); | ||
| 703 | const path = yield* Path.Path; | ||
| 704 | const worktreePath = path.join(baseDir, "worktrees", "feature-a"); | ||
| 705 | yield* makeDirectory(worktreePath); | ||
| 706 | |||
| 707 | yield* manager.open( | ||
| 708 | openInput({ | ||
| 709 | cwd: worktreePath, | ||
| 710 | worktreePath, | ||
| 711 | }), | ||
| 712 | ); | ||
| 713 | |||
| 714 | const process = ptyAdapter.processes[0]; | ||
| 715 | expect(process).toBeDefined(); | ||
| 716 | if (!process) return; | ||
| 717 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 718 | |||
| 719 | yield* waitFor( | ||
| 720 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 721 | ); | ||
| 722 | |||
| 723 | const reopenedSnapshot = yield* manager.open( | ||
| 724 | openInput({ | ||
| 725 | cwd: worktreePath, | ||
| 726 | worktreePath, | ||
| 727 | }), | ||
| 728 | ); | ||
| 729 | |||
| 730 | assert.equal(reopenedSnapshot.worktreePath, worktreePath); | ||
| 731 | |||
| 732 | const events = yield* getEvents; | ||
| 733 | const reopenedEvent = events | ||
| 734 | .toReversed() | ||
| 735 | .find( | ||
| 736 | (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started", | ||
| 737 | ); | ||
| 738 | |||
| 739 | assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); | ||
| 740 | }), | ||
| 741 | ); | ||
| 742 | |||
| 743 | it.effect("emits exited event and reopens with clean transcript after exit", () => | ||
| 744 | Effect.gen(function* () { | ||
| 745 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); | ||
| 746 | const path = yield* Path.Path; | ||
| 747 | yield* manager.open(openInput()); | ||
| 748 | const process = ptyAdapter.processes[0]; | ||
| 749 | expect(process).toBeDefined(); | ||
| 750 | if (!process) return; | ||
| 751 | process.emitData("old data\n"); | ||
| 752 | yield* waitFor( | ||
| 753 | historyLogPath(logsDir).pipe( | ||
| 754 | Effect.provideService(Path.Path, path), | ||
| 755 | Effect.flatMap(pathExists), | ||
| 756 | ), | ||
| 757 | ); | ||
| 758 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 759 | |||
| 760 | yield* waitFor( | ||
| 761 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 762 | ); | ||
| 763 | const reopened = yield* manager.open(openInput()); | ||
| 764 | |||
| 765 | assert.equal(reopened.history, ""); | ||
| 766 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 767 | expect( | ||
| 768 | yield* historyLogPath(logsDir).pipe( | ||
| 769 | Effect.provideService(Path.Path, path), | ||
| 770 | Effect.flatMap(readFileString), | ||
| 771 | ), | ||
| 772 | ).toBe(""); | ||
| 773 | }), | ||
| 774 | ); | ||
| 775 | |||
| 776 | it.effect("ignores trailing writes after terminal exit", () => | ||
| 777 | Effect.gen(function* () { | ||
| 778 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 779 | yield* manager.open(openInput()); | ||
| 780 | const process = ptyAdapter.processes[0]; | ||
| 781 | expect(process).toBeDefined(); | ||
| 782 | if (!process) return; | ||
| 783 | |||
| 784 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 785 | |||
| 786 | yield* manager.write({ | ||
| 787 | threadId: "thread-1", | ||
| 788 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 789 | data: "\r", | ||
| 790 | }); | ||
| 791 | expect(process.writes).toEqual([]); | ||
| 792 | }), | ||
| 793 | ); | ||
| 794 | |||
| 795 | it.effect("emits subprocess activity events when child-process state changes", () => | ||
| 796 | Effect.gen(function* () { | ||
| 797 | let inspect: { | ||
| 798 | readonly hasRunningSubprocess: boolean; | ||
| 799 | readonly childCommand: string | null; | ||
| 800 | readonly processIds: ReadonlyArray<number>; | ||
| 801 | } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 802 | const { manager, getEvents } = yield* createManager(5, { | ||
| 803 | subprocessInspector: () => Effect.succeed(inspect), | ||
| 804 | subprocessPollIntervalMs: 20, | ||
| 805 | }); | ||
| 806 | |||
| 807 | yield* manager.open(openInput()); | ||
| 808 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); | ||
| 809 | |||
| 810 | inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; | ||
| 811 | yield* waitFor( | ||
| 812 | Effect.map(getEvents, (events) => | ||
| 813 | events.some( | ||
| 814 | (event) => | ||
| 815 | event.type === "activity" && | ||
| 816 | event.hasRunningSubprocess === true && | ||
| 817 | event.label === "vim", | ||
| 818 | ), | ||
| 819 | ), | ||
| 820 | "1200 millis", | ||
| 821 | ); | ||
| 822 | |||
| 823 | inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 824 | yield* waitFor( | ||
| 825 | Effect.map(getEvents, (events) => | ||
| 826 | events.some( | ||
| 827 | (event) => | ||
| 828 | event.type === "activity" && | ||
| 829 | event.hasRunningSubprocess === false && | ||
| 830 | event.label === "Terminal 1", | ||
| 831 | ), | ||
| 832 | ), | ||
| 833 | "1200 millis", | ||
| 834 | ); | ||
| 835 | }), | ||
| 836 | ); | ||
| 837 | |||
| 838 | it.effect("does not invoke subprocess polling until a terminal session is running", () => | ||
| 839 | Effect.gen(function* () { | ||
| 840 | let checks = 0; | ||
| 841 | const { manager } = yield* createManager(5, { | ||
| 842 | subprocessInspector: () => { | ||
| 843 | checks += 1; | ||
| 844 | return Effect.succeed({ | ||
| 845 | hasRunningSubprocess: false, | ||
| 846 | childCommand: null, | ||
| 847 | processIds: [], | ||
| 848 | }); | ||
| 849 | }, | ||
| 850 | subprocessPollIntervalMs: 20, | ||
| 851 | }); | ||
| 852 | |||
| 853 | yield* Effect.sleep("80 millis"); | ||
| 854 | assert.equal(checks, 0); | ||
| 855 | |||
| 856 | yield* manager.open(openInput()); | ||
| 857 | yield* waitFor( | ||
| 858 | Effect.sync(() => checks > 0), | ||
| 859 | "1200 millis", | ||
| 860 | ); | ||
| 861 | }), | ||
| 862 | ); | ||
| 863 | |||
| 864 | it.effect("caps persisted history to configured line limit", () => | ||
| 865 | Effect.gen(function* () { | ||
| 866 | const { manager, ptyAdapter } = yield* createManager(3); | ||
| 867 | yield* manager.open(openInput()); | ||
| 868 | const process = ptyAdapter.processes[0]; | ||
| 869 | expect(process).toBeDefined(); | ||
| 870 | if (!process) return; | ||
| 871 | |||
| 872 | process.emitData("line1\nline2\nline3\nline4\n"); | ||
| 873 | yield* manager.close({ threadId: "thread-1" }); | ||
| 874 | |||
| 875 | const reopened = yield* manager.open(openInput()); | ||
| 876 | const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); | ||
| 877 | expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); | ||
| 878 | }), | ||
| 879 | ); | ||
| 880 | |||
| 881 | it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => | ||
| 882 | Effect.gen(function* () { | ||
| 883 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 884 | yield* manager.open(openInput()); | ||
| 885 | const process = ptyAdapter.processes[0]; | ||
| 886 | expect(process).toBeDefined(); | ||
| 887 | if (!process) return; | ||
| 888 | |||
| 889 | process.emitData("prompt "); | ||
| 890 | process.emitData("\u001b[32mok\u001b[0m "); | ||
| 891 | process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); | ||
| 892 | process.emitData("\u001b[1;1R"); | ||
| 893 | process.emitData("done\n"); | ||
| 894 | |||
| 895 | yield* manager.close({ threadId: "thread-1" }); | ||
| 896 | |||
| 897 | const reopened = yield* manager.open(openInput()); | ||
| 898 | assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); | ||
| 899 | }), | ||
| 900 | ); | ||
| 901 | |||
| 902 | it.effect( | ||
| 903 | "preserves clear and style control sequences while dropping chunk-split query traffic", | ||
| 904 | () => | ||
| 905 | Effect.gen(function* () { | ||
| 906 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 907 | yield* manager.open(openInput()); | ||
| 908 | const process = ptyAdapter.processes[0]; | ||
| 909 | expect(process).toBeDefined(); | ||
| 910 | if (!process) return; | ||
| 911 | |||
| 912 | process.emitData("before clear\n"); | ||
| 913 | process.emitData("\u001b[H\u001b[2J"); | ||
| 914 | process.emitData("prompt "); | ||
| 915 | process.emitData("\u001b]11;"); | ||
| 916 | process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); | ||
| 917 | process.emitData("R\u001b[36mdone\u001b[0m\n"); | ||
| 918 | |||
| 919 | yield* manager.close({ threadId: "thread-1" }); | ||
| 920 | |||
| 921 | const reopened = yield* manager.open(openInput()); | ||
| 922 | assert.equal( | ||
| 923 | reopened.history, | ||
| 924 | "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", | ||
| 925 | ); | ||
| 926 | }), | ||
| 927 | ); | ||
| 928 | |||
| 929 | it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => | ||
| 930 | Effect.gen(function* () { | ||
| 931 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 932 | yield* manager.open(openInput()); | ||
| 933 | const process = ptyAdapter.processes[0]; | ||
| 934 | expect(process).toBeDefined(); | ||
| 935 | if (!process) return; | ||
| 936 | |||
| 937 | process.emitData("before "); | ||
| 938 | process.emitData("\u001b(B"); | ||
| 939 | process.emitData("after\n"); | ||
| 940 | |||
| 941 | yield* manager.close({ threadId: "thread-1" }); | ||
| 942 | |||
| 943 | const reopened = yield* manager.open(openInput()); | ||
| 944 | assert.equal(reopened.history, "before \u001b(Bafter\n"); | ||
| 945 | }), | ||
| 946 | ); | ||
| 947 | |||
| 948 | it.effect( | ||
| 949 | "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", | ||
| 950 | () => | ||
| 951 | Effect.gen(function* () { | ||
| 952 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 953 | yield* manager.open(openInput()); | ||
| 954 | const process = ptyAdapter.processes[0]; | ||
| 955 | expect(process).toBeDefined(); | ||
| 956 | if (!process) return; | ||
| 957 | |||
| 958 | process.emitData("before "); | ||
| 959 | process.emitData("\u001b("); | ||
| 960 | process.emitData("Bafter\n"); | ||
| 961 | |||
| 962 | yield* manager.close({ threadId: "thread-1" }); | ||
| 963 | |||
| 964 | const reopened = yield* manager.open(openInput()); | ||
| 965 | assert.equal(reopened.history, "before \u001b(Bafter\n"); | ||
| 966 | }), | ||
| 967 | ); | ||
| 968 | |||
| 969 | it.effect("deletes history file when close(deleteHistory=true)", () => | ||
| 970 | Effect.gen(function* () { | ||
| 971 | const { manager, ptyAdapter, logsDir } = yield* createManager(); | ||
| 972 | yield* manager.open(openInput()); | ||
| 973 | const process = ptyAdapter.processes[0]; | ||
| 974 | expect(process).toBeDefined(); | ||
| 975 | if (!process) return; | ||
| 976 | process.emitData("bye\n"); | ||
| 977 | const path = yield* Path.Path; | ||
| 978 | yield* waitFor( | ||
| 979 | historyLogPath(logsDir).pipe( | ||
| 980 | Effect.provideService(Path.Path, path), | ||
| 981 | Effect.flatMap(pathExists), | ||
| 982 | ), | ||
| 983 | ); | ||
| 984 | |||
| 985 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); | ||
| 986 | expect( | ||
| 987 | yield* historyLogPath(logsDir).pipe( | ||
| 988 | Effect.provideService(Path.Path, path), | ||
| 989 | Effect.flatMap(pathExists), | ||
| 990 | ), | ||
| 991 | ).toBe(false); | ||
| 992 | }), | ||
| 993 | ); | ||
| 994 | |||
| 995 | it.effect("closes all terminals for a thread when close omits terminalId", () => | ||
| 996 | Effect.gen(function* () { | ||
| 997 | const { manager, ptyAdapter, logsDir } = yield* createManager(); | ||
| 998 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 999 | yield* manager.open(openInput({ terminalId: "sidecar" })); | ||
| 1000 | const defaultProcess = ptyAdapter.processes[0]; | ||
| 1001 | const sidecarProcess = ptyAdapter.processes[1]; | ||
| 1002 | expect(defaultProcess).toBeDefined(); | ||
| 1003 | expect(sidecarProcess).toBeDefined(); | ||
| 1004 | if (!defaultProcess || !sidecarProcess) return; | ||
| 1005 | |||
| 1006 | defaultProcess.emitData("default\n"); | ||
| 1007 | sidecarProcess.emitData("sidecar\n"); | ||
| 1008 | const path = yield* Path.Path; | ||
| 1009 | yield* waitFor( | ||
| 1010 | multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( | ||
| 1011 | Effect.provideService(Path.Path, path), | ||
| 1012 | Effect.flatMap(pathExists), | ||
| 1013 | ), | ||
| 1014 | ); | ||
| 1015 | yield* waitFor( | ||
| 1016 | multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( | ||
| 1017 | Effect.provideService(Path.Path, path), | ||
| 1018 | Effect.flatMap(pathExists), | ||
| 1019 | ), | ||
| 1020 | ); | ||
| 1021 | |||
| 1022 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); | ||
| 1023 | |||
| 1024 | assert.equal(defaultProcess.killed, true); | ||
| 1025 | assert.equal(sidecarProcess.killed, true); | ||
| 1026 | expect( | ||
| 1027 | yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( | ||
| 1028 | Effect.provideService(Path.Path, path), | ||
| 1029 | Effect.flatMap(pathExists), | ||
| 1030 | ), | ||
| 1031 | ).toBe(false); | ||
| 1032 | expect( | ||
| 1033 | yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( | ||
| 1034 | Effect.provideService(Path.Path, path), | ||
| 1035 | Effect.flatMap(pathExists), | ||
| 1036 | ), | ||
| 1037 | ).toBe(false); | ||
| 1038 | }), | ||
| 1039 | ); | ||
| 1040 | |||
| 1041 | it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => | ||
| 1042 | Effect.gen(function* () { | ||
| 1043 | const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); | ||
| 1044 | yield* manager.open(openInput()); | ||
| 1045 | const process = ptyAdapter.processes[0]; | ||
| 1046 | expect(process).toBeDefined(); | ||
| 1047 | if (!process) return; | ||
| 1048 | |||
| 1049 | const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); | ||
| 1050 | yield* Effect.yieldNow; | ||
| 1051 | yield* TestClock.adjust("10 millis"); | ||
| 1052 | yield* Fiber.join(closeFiber); | ||
| 1053 | |||
| 1054 | assert.equal(process.killSignals[0], "SIGTERM"); | ||
| 1055 | expect(process.killSignals).toContain("SIGKILL"); | ||
| 1056 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 1057 | ); | ||
| 1058 | |||
| 1059 | it.effect("publishes closed events when terminals are explicitly closed", () => | ||
| 1060 | Effect.gen(function* () { | ||
| 1061 | const { manager, getEvents } = yield* createManager(); | ||
| 1062 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 1063 | yield* manager.open(openInput({ terminalId: "sidecar" })); | ||
| 1064 | |||
| 1065 | yield* manager.close({ threadId: "thread-1" }); | ||
| 1066 | |||
| 1067 | const closedEvents = (yield* getEvents).filter( | ||
| 1068 | (event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed", | ||
| 1069 | ); | ||
| 1070 | expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); | ||
| 1071 | }), | ||
| 1072 | ); | ||
| 1073 | |||
| 1074 | it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => | ||
| 1075 | Effect.gen(function* () { | ||
| 1076 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { | ||
| 1077 | maxRetainedInactiveSessions: 1, | ||
| 1078 | }); | ||
| 1079 | |||
| 1080 | yield* manager.open(openInput({ threadId: "thread-1" })); | ||
| 1081 | yield* manager.open(openInput({ threadId: "thread-2" })); | ||
| 1082 | |||
| 1083 | const first = ptyAdapter.processes[0]; | ||
| 1084 | const second = ptyAdapter.processes[1]; | ||
| 1085 | expect(first).toBeDefined(); | ||
| 1086 | expect(second).toBeDefined(); | ||
| 1087 | if (!first || !second) return; | ||
| 1088 | |||
| 1089 | first.emitData("first-history\n"); | ||
| 1090 | second.emitData("second-history\n"); | ||
| 1091 | const path = yield* Path.Path; | ||
| 1092 | yield* waitFor( | ||
| 1093 | historyLogPath(logsDir, "thread-1").pipe( | ||
| 1094 | Effect.provideService(Path.Path, path), | ||
| 1095 | Effect.flatMap(pathExists), | ||
| 1096 | ), | ||
| 1097 | ); | ||
| 1098 | first.emitExit({ exitCode: 0, signal: 0 }); | ||
| 1099 | yield* Effect.sleep(Duration.millis(5)); | ||
| 1100 | second.emitExit({ exitCode: 0, signal: 0 }); | ||
| 1101 | |||
| 1102 | yield* waitFor( | ||
| 1103 | Effect.map( | ||
| 1104 | getEvents, | ||
| 1105 | (events) => events.filter((event) => event.type === "exited").length === 2, | ||
| 1106 | ), | ||
| 1107 | ); | ||
| 1108 | |||
| 1109 | const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); | ||
| 1110 | const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); | ||
| 1111 | |||
| 1112 | assert.equal(reopenedFirst.history, "first-history\n"); | ||
| 1113 | assert.equal(reopenedSecond.history, ""); | ||
| 1114 | }), | ||
| 1115 | ); | ||
| 1116 | |||
| 1117 | it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => | ||
| 1118 | Effect.gen(function* () { | ||
| 1119 | const { manager, logsDir } = yield* createManager(); | ||
| 1120 | const path = yield* Path.Path; | ||
| 1121 | const legacyPath = path.join(logsDir, "thread-1.log"); | ||
| 1122 | const nextPath = yield* historyLogPath(logsDir); | ||
| 1123 | yield* writeFileString(legacyPath, "legacy-line\n"); | ||
| 1124 | |||
| 1125 | const snapshot = yield* manager.open(openInput()); | ||
| 1126 | |||
| 1127 | assert.equal(snapshot.history, "legacy-line\n"); | ||
| 1128 | expect(yield* pathExists(nextPath)).toBe(true); | ||
| 1129 | expect(yield* readFileString(nextPath)).toBe("legacy-line\n"); | ||
| 1130 | expect(yield* pathExists(legacyPath)).toBe(false); | ||
| 1131 | }), | ||
| 1132 | ); | ||
| 1133 | |||
| 1134 | it.effect("retries with fallback shells when preferred shell spawn fails", () => | ||
| 1135 | Effect.gen(function* () { | ||
| 1136 | const platform = yield* HostProcessPlatform; | ||
| 1137 | const missingShell = | ||
| 1138 | platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; | ||
| 1139 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1140 | shellResolver: () => missingShell, | ||
| 1141 | }); | ||
| 1142 | ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); | ||
| 1143 | |||
| 1144 | const snapshot = yield* manager.open(openInput()); | ||
| 1145 | |||
| 1146 | assert.equal(snapshot.status, "running"); | ||
| 1147 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); | ||
| 1148 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe( | ||
| 1149 | platform === "win32" ? missingShell : "/definitely/missing-shell", | ||
| 1150 | ); | ||
| 1151 | |||
| 1152 | if (platform === "win32") { | ||
| 1153 | expect( | ||
| 1154 | ptyAdapter.spawnInputs.some( | ||
| 1155 | (input) => | ||
| 1156 | input.shell === "pwsh.exe" || | ||
| 1157 | input.shell === "powershell.exe" || | ||
| 1158 | input.shell === "cmd.exe", | ||
| 1159 | ), | ||
| 1160 | ).toBe(true); | ||
| 1161 | } else { | ||
| 1162 | expect( | ||
| 1163 | ptyAdapter.spawnInputs | ||
| 1164 | .slice(1) | ||
| 1165 | .some((input) => input.shell !== "/definitely/missing-shell"), | ||
| 1166 | ).toBe(true); | ||
| 1167 | } | ||
| 1168 | }), | ||
| 1169 | ); | ||
| 1170 | |||
| 1171 | it.effect("prefers PowerShell over ComSpec for Windows terminals", () => | ||
| 1172 | Effect.gen(function* () { | ||
| 1173 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1174 | env: { | ||
| 1175 | ComSpec: "C:\\Windows\\System32\\cmd.exe", | ||
| 1176 | PATH: "C:\\Windows\\System32", | ||
| 1177 | SystemRoot: "C:\\Windows", | ||
| 1178 | }, | ||
| 1179 | }).pipe(Effect.provide(withHostPlatform("win32"))); | ||
| 1180 | |||
| 1181 | yield* manager.open(openInput()); | ||
| 1182 | |||
| 1183 | expect(ptyAdapter.spawnInputs[0]).toEqual( | ||
| 1184 | expect.objectContaining({ | ||
| 1185 | shell: "pwsh.exe", | ||
| 1186 | args: ["-NoLogo"], | ||
| 1187 | }), | ||
| 1188 | ); | ||
| 1189 | }), | ||
| 1190 | ); | ||
| 1191 | |||
| 1192 | it.effect("falls back to built-in PowerShell by absolute path on Windows", () => | ||
| 1193 | Effect.gen(function* () { | ||
| 1194 | const ptyAdapter = new FakePtyAdapter(); | ||
| 1195 | const { manager } = yield* createManager(5, { | ||
| 1196 | ptyAdapter, | ||
| 1197 | shellResolver: () => "C:\\missing\\custom-shell.exe", | ||
| 1198 | env: { | ||
| 1199 | ComSpec: "C:\\Windows\\System32\\cmd.exe", | ||
| 1200 | PATH: "C:\\Windows\\System32", | ||
| 1201 | SystemRoot: "C:\\Windows", | ||
| 1202 | }, | ||
| 1203 | }).pipe(Effect.provide(withHostPlatform("win32"))); | ||
| 1204 | ptyAdapter.spawnFailures.push( | ||
| 1205 | new Error("spawn custom-shell.exe ENOENT"), | ||
| 1206 | new Error("spawn pwsh.exe ENOENT"), | ||
| 1207 | ); | ||
| 1208 | |||
| 1209 | yield* manager.open(openInput()); | ||
| 1210 | |||
| 1211 | expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ | ||
| 1212 | "C:\\missing\\custom-shell.exe", | ||
| 1213 | "pwsh.exe", | ||
| 1214 | "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", | ||
| 1215 | ]); | ||
| 1216 | expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); | ||
| 1217 | expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); | ||
| 1218 | }), | ||
| 1219 | ); | ||
| 1220 | |||
| 1221 | it.effect("filters app runtime env variables from terminal sessions", () => | ||
| 1222 | Effect.gen(function* () { | ||
| 1223 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1224 | env: { | ||
| 1225 | PORT: "5173", | ||
| 1226 | T3CODE_PORT: "3773", | ||
| 1227 | VITE_DEV_SERVER_URL: "http://localhost:5173", | ||
| 1228 | TEST_TERMINAL_KEEP: "keep-me", | ||
| 1229 | }, | ||
| 1230 | }); | ||
| 1231 | yield* manager.open(openInput()); | ||
| 1232 | const spawnInput = ptyAdapter.spawnInputs[0]; | ||
| 1233 | expect(spawnInput).toBeDefined(); | ||
| 1234 | if (!spawnInput) return; | ||
| 1235 | |||
| 1236 | expect(spawnInput.env.PORT).toBeUndefined(); | ||
| 1237 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | ||
| 1238 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | ||
| 1239 | // Arbitrary host env vars must pass through — terminals inherit the | ||
| 1240 | // user's environment apart from the explicit blocklist. | ||
| 1241 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | ||
| 1242 | }), | ||
| 1243 | ); | ||
| 1244 | |||
| 1245 | it.effect("injects runtime env overrides into spawned terminals", () => | ||
| 1246 | Effect.gen(function* () { | ||
| 1247 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 1248 | yield* manager.open( | ||
| 1249 | openInput({ | ||
| 1250 | env: { | ||
| 1251 | T3CODE_PROJECT_ROOT: "/repo", | ||
| 1252 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | ||
| 1253 | CUSTOM_FLAG: "1", | ||
| 1254 | }, | ||
| 1255 | }), | ||
| 1256 | ); | ||
| 1257 | const spawnInput = ptyAdapter.spawnInputs[0]; | ||
| 1258 | expect(spawnInput).toBeDefined(); | ||
| 1259 | if (!spawnInput) return; | ||
| 1260 | |||
| 1261 | assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); | ||
| 1262 | assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); | ||
| 1263 | assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); | ||
| 1264 | }), | ||
| 1265 | ); | ||
| 1266 | |||
| 1267 | it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => | ||
| 1268 | Effect.gen(function* () { | ||
| 1269 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 1270 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1271 | shellResolver: () => "/bin/zsh", | ||
| 1272 | }); | ||
| 1273 | yield* manager.open(openInput()); | ||
| 1274 | const spawnInput = ptyAdapter.spawnInputs[0]; | ||
| 1275 | expect(spawnInput).toBeDefined(); | ||
| 1276 | if (!spawnInput) return; | ||
| 1277 | |||
| 1278 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | ||
| 1279 | }), | ||
| 1280 | ); | ||
| 1281 | |||
| 1282 | it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => | ||
| 1283 | Effect.gen(function* () { | ||
| 1284 | const { manager, ptyAdapter, getEvents } = yield* createManager(5, { | ||
| 1285 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1286 | }); | ||
| 1287 | |||
| 1288 | yield* manager.open(openInput()); | ||
| 1289 | const process = ptyAdapter.processes[0]; | ||
| 1290 | expect(process).toBeDefined(); | ||
| 1291 | if (!process) return; | ||
| 1292 | |||
| 1293 | process.emitData("hello from callback\n"); | ||
| 1294 | |||
| 1295 | yield* waitFor( | ||
| 1296 | Effect.map(getEvents, (events) => | ||
| 1297 | events.some((event) => event.type === "output" && event.data === "hello from callback\n"), | ||
| 1298 | ), | ||
| 1299 | "1200 millis", | ||
| 1300 | ); | ||
| 1301 | }), | ||
| 1302 | ); | ||
| 1303 | |||
| 1304 | it.effect("pushes PTY callbacks to direct event subscribers", () => | ||
| 1305 | Effect.gen(function* () { | ||
| 1306 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1307 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1308 | }); | ||
| 1309 | const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); | ||
| 1310 | const unsubscribe = yield* manager.subscribe((event) => | ||
| 1311 | Ref.update(subscriberEvents, (events) => [...events, event]), | ||
| 1312 | ); | ||
| 1313 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1314 | |||
| 1315 | yield* manager.open(openInput()); | ||
| 1316 | const process = ptyAdapter.processes[0]; | ||
| 1317 | expect(process).toBeDefined(); | ||
| 1318 | if (!process) return; | ||
| 1319 | |||
| 1320 | process.emitData("hello from subscriber\n"); | ||
| 1321 | |||
| 1322 | yield* waitFor( | ||
| 1323 | Effect.map(Ref.get(subscriberEvents), (events) => | ||
| 1324 | events.some( | ||
| 1325 | (event) => event.type === "output" && event.data === "hello from subscriber\n", | ||
| 1326 | ), | ||
| 1327 | ), | ||
| 1328 | "1200 millis", | ||
| 1329 | ); | ||
| 1330 | }), | ||
| 1331 | ); | ||
| 1332 | |||
| 1333 | it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => | ||
| 1334 | Effect.gen(function* () { | ||
| 1335 | const { manager } = yield* createManager(); | ||
| 1336 | yield* manager.open(openInput({ threadId: "existing-thread" })); | ||
| 1337 | |||
| 1338 | const metadataEvents = yield* Ref.make<ReadonlyArray<TerminalMetadataStreamEvent>>([]); | ||
| 1339 | const unsubscribe = yield* manager.subscribeMetadata((event) => | ||
| 1340 | Ref.update(metadataEvents, (events) => [...events, event]), | ||
| 1341 | ); | ||
| 1342 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1343 | |||
| 1344 | const initialEvents = yield* Ref.get(metadataEvents); | ||
| 1345 | expect(initialEvents[0]).toMatchObject({ | ||
| 1346 | type: "snapshot", | ||
| 1347 | terminals: [ | ||
| 1348 | { | ||
| 1349 | threadId: "existing-thread", | ||
| 1350 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1351 | }, | ||
| 1352 | ], | ||
| 1353 | }); | ||
| 1354 | |||
| 1355 | yield* manager.open(openInput({ threadId: "new-thread" })); | ||
| 1356 | |||
| 1357 | yield* waitFor( | ||
| 1358 | Effect.map(Ref.get(metadataEvents), (events) => | ||
| 1359 | events.some( | ||
| 1360 | (event) => | ||
| 1361 | event.type === "upsert" && | ||
| 1362 | event.terminal.threadId === "new-thread" && | ||
| 1363 | event.terminal.terminalId === DEFAULT_TERMINAL_ID, | ||
| 1364 | ), | ||
| 1365 | ), | ||
| 1366 | "1200 millis", | ||
| 1367 | ); | ||
| 1368 | |||
| 1369 | yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); | ||
| 1370 | |||
| 1371 | yield* waitFor( | ||
| 1372 | Effect.map(Ref.get(metadataEvents), (events) => | ||
| 1373 | events.some( | ||
| 1374 | (event) => | ||
| 1375 | event.type === "remove" && | ||
| 1376 | event.threadId === "new-thread" && | ||
| 1377 | event.terminalId === DEFAULT_TERMINAL_ID, | ||
| 1378 | ), | ||
| 1379 | ), | ||
| 1380 | "1200 millis", | ||
| 1381 | ); | ||
| 1382 | }), | ||
| 1383 | ); | ||
| 1384 | |||
| 1385 | it.effect("removes terminal metadata subscriptions when initial delivery fails", () => | ||
| 1386 | Effect.gen(function* () { | ||
| 1387 | const { manager } = yield* createManager(); | ||
| 1388 | yield* manager.open(openInput({ threadId: "existing-thread" })); | ||
| 1389 | |||
| 1390 | const leakedLiveEvents = yield* Ref.make(0); | ||
| 1391 | const exit = yield* Effect.exit( | ||
| 1392 | manager.subscribeMetadata((event) => | ||
| 1393 | event.type === "snapshot" | ||
| 1394 | ? Effect.die("snapshot listener failed") | ||
| 1395 | : Ref.update(leakedLiveEvents, (count) => count + 1), | ||
| 1396 | ), | ||
| 1397 | ); | ||
| 1398 | |||
| 1399 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 1400 | |||
| 1401 | yield* manager.open(openInput({ threadId: "new-thread" })); | ||
| 1402 | expect(yield* Ref.get(leakedLiveEvents)).toBe(0); | ||
| 1403 | }), | ||
| 1404 | ); | ||
| 1405 | |||
| 1406 | it.effect( | ||
| 1407 | "streams attach snapshots followed by live events without duplicate start snapshots", | ||
| 1408 | () => | ||
| 1409 | Effect.gen(function* () { | ||
| 1410 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1411 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1412 | }); | ||
| 1413 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1414 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 1415 | Ref.update(attachEvents, (events) => [...events, event]), | ||
| 1416 | ); | ||
| 1417 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1418 | |||
| 1419 | const process = ptyAdapter.processes[0]; | ||
| 1420 | expect(process).toBeDefined(); | ||
| 1421 | if (!process) return; | ||
| 1422 | |||
| 1423 | expect(yield* Ref.get(attachEvents)).toMatchObject([ | ||
| 1424 | { | ||
| 1425 | type: "snapshot", | ||
| 1426 | snapshot: { | ||
| 1427 | threadId: "thread-1", | ||
| 1428 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1429 | }, | ||
| 1430 | }, | ||
| 1431 | ]); | ||
| 1432 | |||
| 1433 | process.emitData("hello from attach\n"); | ||
| 1434 | |||
| 1435 | yield* waitFor( | ||
| 1436 | Effect.map(Ref.get(attachEvents), (events) => | ||
| 1437 | events.some((event) => event.type === "output" && event.data === "hello from attach\n"), | ||
| 1438 | ), | ||
| 1439 | "1200 millis", | ||
| 1440 | ); | ||
| 1441 | |||
| 1442 | const events = yield* Ref.get(attachEvents); | ||
| 1443 | expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); | ||
| 1444 | }), | ||
| 1445 | ); | ||
| 1446 | |||
| 1447 | it.effect("buffers attach output delivered during the initial snapshot callback", () => | ||
| 1448 | Effect.gen(function* () { | ||
| 1449 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1450 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1451 | }); | ||
| 1452 | yield* manager.open(openInput()); | ||
| 1453 | |||
| 1454 | const process = ptyAdapter.processes[0]; | ||
| 1455 | expect(process).toBeDefined(); | ||
| 1456 | if (!process) return; | ||
| 1457 | |||
| 1458 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1459 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 1460 | Effect.gen(function* () { | ||
| 1461 | yield* Ref.update(attachEvents, (events) => [...events, event]); | ||
| 1462 | if (event.type === "snapshot") { | ||
| 1463 | yield* Effect.sync(() => process.emitData("during snapshot\n")); | ||
| 1464 | yield* Effect.yieldNow; | ||
| 1465 | } | ||
| 1466 | }), | ||
| 1467 | ); | ||
| 1468 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1469 | |||
| 1470 | yield* waitFor( | ||
| 1471 | Effect.map(Ref.get(attachEvents), (events) => | ||
| 1472 | events.some((event) => event.type === "output" && event.data === "during snapshot\n"), | ||
| 1473 | ), | ||
| 1474 | "1200 millis", | ||
| 1475 | ); | ||
| 1476 | |||
| 1477 | expect(yield* Ref.get(attachEvents)).toMatchObject([ | ||
| 1478 | { type: "snapshot" }, | ||
| 1479 | { type: "output", data: "during snapshot\n" }, | ||
| 1480 | ]); | ||
| 1481 | }), | ||
| 1482 | ); | ||
| 1483 | |||
| 1484 | it.effect("preserves queued PTY output ordering through exit callbacks", () => | ||
| 1485 | Effect.gen(function* () { | ||
| 1486 | const { manager, ptyAdapter, getEvents } = yield* createManager(5, { | ||
| 1487 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1488 | }); | ||
| 1489 | |||
| 1490 | yield* manager.open(openInput()); | ||
| 1491 | const process = ptyAdapter.processes[0]; | ||
| 1492 | expect(process).toBeDefined(); | ||
| 1493 | if (!process) return; | ||
| 1494 | |||
| 1495 | process.emitData("first\n"); | ||
| 1496 | process.emitData("second\n"); | ||
| 1497 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 1498 | |||
| 1499 | yield* waitFor( | ||
| 1500 | Effect.map(getEvents, (events) => { | ||
| 1501 | const relevant = events.filter( | ||
| 1502 | (event) => event.type === "output" || event.type === "exited", | ||
| 1503 | ); | ||
| 1504 | return relevant.length >= 3; | ||
| 1505 | }), | ||
| 1506 | "1200 millis", | ||
| 1507 | ); | ||
| 1508 | |||
| 1509 | const relevant = (yield* getEvents).filter( | ||
| 1510 | (event) => event.type === "output" || event.type === "exited", | ||
| 1511 | ); | ||
| 1512 | expect(relevant).toEqual([ | ||
| 1513 | expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), | ||
| 1514 | expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), | ||
| 1515 | expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), | ||
| 1516 | ]); | ||
| 1517 | |||
| 1518 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1519 | const unsubscribe = yield* manager.attachStream( | ||
| 1520 | { | ||
| 1521 | threadId: "thread-1", | ||
| 1522 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1523 | }, | ||
| 1524 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 1525 | ); | ||
| 1526 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1527 | |||
| 1528 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 1529 | expect(snapshot).toBeDefined(); | ||
| 1530 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 1531 | expect(snapshot.snapshot.sequence).toBe(4); | ||
| 1532 | }), | ||
| 1533 | ); | ||
| 1534 | |||
| 1535 | it.effect("scoped runtime shutdown stops active terminals cleanly", () => | ||
| 1536 | Effect.gen(function* () { | ||
| 1537 | const scope = yield* Scope.make("sequential"); | ||
| 1538 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1539 | processKillGraceMs: 10, | ||
| 1540 | }).pipe(Effect.provideService(Scope.Scope, scope)); | ||
| 1541 | yield* manager.open(openInput()); | ||
| 1542 | const process = ptyAdapter.processes[0]; | ||
| 1543 | expect(process).toBeDefined(); | ||
| 1544 | if (!process) return; | ||
| 1545 | |||
| 1546 | const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); | ||
| 1547 | yield* Effect.yieldNow; | ||
| 1548 | yield* TestClock.adjust("10 millis"); | ||
| 1549 | yield* Fiber.join(closeScope); | ||
| 1550 | |||
| 1551 | assert.equal(process.killSignals[0], "SIGTERM"); | ||
| 1552 | expect(process.killSignals).toContain("SIGKILL"); | ||
| 1553 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 1554 | ); | ||
| 1555 | }); | ||
apps/server/src/terminal/Layers/Manager.ts deleted-2488| ... | @@ -1,2488 +0,0 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_TERMINAL_ID, | ||
| 3 | type TerminalAttachInput, | ||
| 4 | type TerminalAttachStreamEvent, | ||
| 5 | type TerminalEvent, | ||
| 6 | type TerminalMetadataStreamEvent, | ||
| 7 | type TerminalOpenInput, | ||
| 8 | type TerminalResizeInput, | ||
| 9 | type TerminalSessionSnapshot, | ||
| 10 | type TerminalSessionStatus, | ||
| 11 | type TerminalSummary, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; | ||
| 14 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 15 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 16 | import * as DateTime from "effect/DateTime"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as Encoding from "effect/Encoding"; | ||
| 19 | import * as Equal from "effect/Equal"; | ||
| 20 | import * as Exit from "effect/Exit"; | ||
| 21 | import * as Fiber from "effect/Fiber"; | ||
| 22 | import * as FileSystem from "effect/FileSystem"; | ||
| 23 | import * as Layer from "effect/Layer"; | ||
| 24 | import * as Option from "effect/Option"; | ||
| 25 | import * as Path from "effect/Path"; | ||
| 26 | import * as Schema from "effect/Schema"; | ||
| 27 | import * as Scope from "effect/Scope"; | ||
| 28 | import * as Semaphore from "effect/Semaphore"; | ||
| 29 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 30 | |||
| 31 | import { ServerConfig } from "../../config.ts"; | ||
| 32 | import { | ||
| 33 | increment, | ||
| 34 | terminalRestartsTotal, | ||
| 35 | terminalSessionsTotal, | ||
| 36 | } from "../../observability/Metrics.ts"; | ||
| 37 | import * as ProcessRunner from "../../processRunner.ts"; | ||
| 38 | import * as PortScanner from "../../preview/PortScanner.ts"; | ||
| 39 | import { | ||
| 40 | TerminalCwdError, | ||
| 41 | TerminalHistoryError, | ||
| 42 | TerminalManager, | ||
| 43 | TerminalNotRunningError, | ||
| 44 | TerminalSessionLookupError, | ||
| 45 | type TerminalManagerShape, | ||
| 46 | } from "../Services/Manager.ts"; | ||
| 47 | import { | ||
| 48 | PtyAdapter, | ||
| 49 | PtySpawnError, | ||
| 50 | type PtyAdapterShape, | ||
| 51 | type PtyExitEvent, | ||
| 52 | type PtyProcess, | ||
| 53 | } from "../Services/PTY.ts"; | ||
| 54 | |||
| 55 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | ||
| 56 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | ||
| 57 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | ||
| 58 | const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; | ||
| 59 | const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; | ||
| 60 | const DEFAULT_OPEN_COLS = 120; | ||
| 61 | const DEFAULT_OPEN_ROWS = 30; | ||
| 62 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | ||
| 63 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | ||
| 64 | const MAX_TERMINAL_LABEL_LENGTH = 128; | ||
| 65 | |||
| 66 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( | ||
| 67 | "TerminalSubprocessCheckError", | ||
| 68 | { | ||
| 69 | message: Schema.String, | ||
| 70 | cause: Schema.optional(Schema.Defect()), | ||
| 71 | terminalPid: Schema.Number, | ||
| 72 | command: Schema.Literals(["powershell", "pgrep", "ps"]), | ||
| 73 | }, | ||
| 74 | ) {} | ||
| 75 | |||
| 76 | class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcessSignalError>()( | ||
| 77 | "TerminalProcessSignalError", | ||
| 78 | { | ||
| 79 | message: Schema.String, | ||
| 80 | cause: Schema.optional(Schema.Defect()), | ||
| 81 | signal: Schema.Literals(["SIGTERM", "SIGKILL"]), | ||
| 82 | }, | ||
| 83 | ) {} | ||
| 84 | |||
| 85 | interface TerminalSubprocessInspectResult { | ||
| 86 | readonly hasRunningSubprocess: boolean; | ||
| 87 | readonly childCommand: string | null; | ||
| 88 | readonly processIds: ReadonlyArray<number>; | ||
| 89 | } | ||
| 90 | |||
| 91 | interface TerminalSubprocessInspector { | ||
| 92 | ( | ||
| 93 | terminalPid: number, | ||
| 94 | ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>; | ||
| 95 | } | ||
| 96 | |||
| 97 | interface ShellCandidate { | ||
| 98 | shell: string; | ||
| 99 | args?: string[]; | ||
| 100 | } | ||
| 101 | |||
| 102 | interface TerminalStartInput { | ||
| 103 | threadId: string; | ||
| 104 | terminalId: string; | ||
| 105 | cwd: string; | ||
| 106 | worktreePath?: string | null; | ||
| 107 | cols: number; | ||
| 108 | rows: number; | ||
| 109 | env?: Record<string, string>; | ||
| 110 | } | ||
| 111 | |||
| 112 | interface TerminalSessionState { | ||
| 113 | threadId: string; | ||
| 114 | terminalId: string; | ||
| 115 | cwd: string; | ||
| 116 | worktreePath: string | null; | ||
| 117 | status: TerminalSessionStatus; | ||
| 118 | pid: number | null; | ||
| 119 | history: string; | ||
| 120 | pendingHistoryControlSequence: string; | ||
| 121 | pendingProcessEvents: Array<PendingProcessEvent>; | ||
| 122 | pendingProcessEventIndex: number; | ||
| 123 | processEventDrainRunning: boolean; | ||
| 124 | exitCode: number | null; | ||
| 125 | exitSignal: number | null; | ||
| 126 | updatedAt: string; | ||
| 127 | eventSequence: number; | ||
| 128 | cols: number; | ||
| 129 | rows: number; | ||
| 130 | process: PtyProcess | null; | ||
| 131 | unsubscribeData: (() => void) | null; | ||
| 132 | unsubscribeExit: (() => void) | null; | ||
| 133 | hasRunningSubprocess: boolean; | ||
| 134 | /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ | ||
| 135 | childCommandLabel: string | null; | ||
| 136 | runtimeEnv: Record<string, string> | null; | ||
| 137 | } | ||
| 138 | |||
| 139 | interface PersistHistoryRequest { | ||
| 140 | history: string; | ||
| 141 | immediate: boolean; | ||
| 142 | } | ||
| 143 | |||
| 144 | type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent }; | ||
| 145 | |||
| 146 | type DrainProcessEventAction = | ||
| 147 | | { type: "idle" } | ||
| 148 | | { | ||
| 149 | type: "output"; | ||
| 150 | threadId: string; | ||
| 151 | terminalId: string; | ||
| 152 | sequence: number; | ||
| 153 | history: string | null; | ||
| 154 | data: string; | ||
| 155 | } | ||
| 156 | | { | ||
| 157 | type: "exit"; | ||
| 158 | process: PtyProcess | null; | ||
| 159 | threadId: string; | ||
| 160 | terminalId: string; | ||
| 161 | sequence: number; | ||
| 162 | exitCode: number | null; | ||
| 163 | exitSignal: number | null; | ||
| 164 | }; | ||
| 165 | |||
| 166 | interface TerminalManagerState { | ||
| 167 | sessions: Map<string, TerminalSessionState>; | ||
| 168 | killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>; | ||
| 169 | } | ||
| 170 | |||
| 171 | function truncateTerminalWireLabel(value: string): string { | ||
| 172 | if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; | ||
| 173 | return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); | ||
| 174 | } | ||
| 175 | |||
| 176 | function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { | ||
| 177 | let trimmed = raw.trim(); | ||
| 178 | if (trimmed.length === 0) return null; | ||
| 179 | if ( | ||
| 180 | (trimmed.startsWith("[") && trimmed.endsWith("]")) || | ||
| 181 | (trimmed.startsWith("(") && trimmed.endsWith(")")) | ||
| 182 | ) { | ||
| 183 | trimmed = trimmed.slice(1, -1).trim(); | ||
| 184 | } | ||
| 185 | const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); | ||
| 186 | if (firstToken.length === 0) return null; | ||
| 187 | const separators = platform === "win32" ? /[\\/]/ : /\//; | ||
| 188 | const base = firstToken.split(separators).at(-1) ?? firstToken; | ||
| 189 | const withoutExe = | ||
| 190 | platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; | ||
| 191 | return withoutExe.length > 0 ? withoutExe : null; | ||
| 192 | } | ||
| 193 | |||
| 194 | function terminalWireLabel(session: TerminalSessionState): string { | ||
| 195 | if (session.hasRunningSubprocess && session.childCommandLabel) { | ||
| 196 | const trimmed = session.childCommandLabel.trim(); | ||
| 197 | if (trimmed.length > 0) { | ||
| 198 | return truncateTerminalWireLabel(trimmed); | ||
| 199 | } | ||
| 200 | } | ||
| 201 | return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); | ||
| 202 | } | ||
| 203 | |||
| 204 | function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | ||
| 205 | return { | ||
| 206 | threadId: session.threadId, | ||
| 207 | terminalId: session.terminalId, | ||
| 208 | cwd: session.cwd, | ||
| 209 | worktreePath: session.worktreePath, | ||
| 210 | status: session.status, | ||
| 211 | pid: session.pid, | ||
| 212 | history: session.history, | ||
| 213 | exitCode: session.exitCode, | ||
| 214 | exitSignal: session.exitSignal, | ||
| 215 | label: terminalWireLabel(session), | ||
| 216 | updatedAt: session.updatedAt, | ||
| 217 | sequence: session.eventSequence, | ||
| 218 | }; | ||
| 219 | } | ||
| 220 | |||
| 221 | function summary(session: TerminalSessionState): TerminalSummary { | ||
| 222 | return { | ||
| 223 | threadId: session.threadId, | ||
| 224 | terminalId: session.terminalId, | ||
| 225 | cwd: session.cwd, | ||
| 226 | worktreePath: session.worktreePath, | ||
| 227 | status: session.status, | ||
| 228 | pid: session.pid, | ||
| 229 | exitCode: session.exitCode, | ||
| 230 | exitSignal: session.exitSignal, | ||
| 231 | hasRunningSubprocess: session.hasRunningSubprocess, | ||
| 232 | label: terminalWireLabel(session), | ||
| 233 | updatedAt: session.updatedAt, | ||
| 234 | }; | ||
| 235 | } | ||
| 236 | |||
| 237 | function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { | ||
| 238 | switch (event.type) { | ||
| 239 | case "started": | ||
| 240 | case "restarted": | ||
| 241 | case "exited": | ||
| 242 | case "closed": | ||
| 243 | case "error": | ||
| 244 | case "activity": | ||
| 245 | return true; | ||
| 246 | case "output": | ||
| 247 | case "cleared": | ||
| 248 | return false; | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { | ||
| 253 | switch (event.type) { | ||
| 254 | case "started": | ||
| 255 | return { | ||
| 256 | type: "snapshot", | ||
| 257 | snapshot: event.snapshot, | ||
| 258 | }; | ||
| 259 | case "output": | ||
| 260 | case "exited": | ||
| 261 | case "closed": | ||
| 262 | case "error": | ||
| 263 | case "cleared": | ||
| 264 | case "restarted": | ||
| 265 | case "activity": | ||
| 266 | return event; | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | function isDuplicateAttachSnapshotEvent( | ||
| 271 | event: TerminalEvent, | ||
| 272 | initialSnapshot: TerminalSessionSnapshot, | ||
| 273 | ) { | ||
| 274 | return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" | ||
| 275 | ? event.sequence <= initialSnapshot.sequence | ||
| 276 | : event.type === "started" && | ||
| 277 | event.snapshot.threadId === initialSnapshot.threadId && | ||
| 278 | event.snapshot.terminalId === initialSnapshot.terminalId && | ||
| 279 | event.snapshot.updatedAt <= initialSnapshot.updatedAt; | ||
| 280 | } | ||
| 281 | |||
| 282 | function advanceEventSequence(session: TerminalSessionState): { | ||
| 283 | readonly updatedAt: string; | ||
| 284 | readonly sequence: number; | ||
| 285 | } { | ||
| 286 | const updatedAt = DateTime.formatIso(DateTime.nowUnsafe()); | ||
| 287 | session.eventSequence += 1; | ||
| 288 | session.updatedAt = updatedAt; | ||
| 289 | return { updatedAt, sequence: session.eventSequence }; | ||
| 290 | } | ||
| 291 | |||
| 292 | function cleanupProcessHandles(session: TerminalSessionState): void { | ||
| 293 | session.unsubscribeData?.(); | ||
| 294 | session.unsubscribeData = null; | ||
| 295 | session.unsubscribeExit?.(); | ||
| 296 | session.unsubscribeExit = null; | ||
| 297 | } | ||
| 298 | |||
| 299 | function enqueueProcessEvent( | ||
| 300 | session: TerminalSessionState, | ||
| 301 | expectedPid: number, | ||
| 302 | event: PendingProcessEvent, | ||
| 303 | ): boolean { | ||
| 304 | if (!session.process || session.status !== "running" || session.pid !== expectedPid) { | ||
| 305 | return false; | ||
| 306 | } | ||
| 307 | |||
| 308 | session.pendingProcessEvents.push(event); | ||
| 309 | if (session.processEventDrainRunning) { | ||
| 310 | return false; | ||
| 311 | } | ||
| 312 | |||
| 313 | session.processEventDrainRunning = true; | ||
| 314 | return true; | ||
| 315 | } | ||
| 316 | |||
| 317 | function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { | ||
| 318 | if (platform === "win32") { | ||
| 319 | return "pwsh.exe"; | ||
| 320 | } | ||
| 321 | return env.SHELL ?? "bash"; | ||
| 322 | } | ||
| 323 | |||
| 324 | function normalizeShellCommand( | ||
| 325 | value: string | undefined, | ||
| 326 | platform: NodeJS.Platform, | ||
| 327 | ): string | null { | ||
| 328 | if (!value) return null; | ||
| 329 | const trimmed = value.trim(); | ||
| 330 | if (trimmed.length === 0) return null; | ||
| 331 | |||
| 332 | if (platform === "win32") { | ||
| 333 | return trimmed; | ||
| 334 | } | ||
| 335 | |||
| 336 | const firstToken = trimmed.split(/\s+/g)[0]?.trim(); | ||
| 337 | if (!firstToken) return null; | ||
| 338 | return firstToken.replace(/^['"]|['"]$/g, ""); | ||
| 339 | } | ||
| 340 | |||
| 341 | function basenameForPlatform(command: string, platform: NodeJS.Platform): string { | ||
| 342 | const normalized = | ||
| 343 | platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); | ||
| 344 | const parts = normalized | ||
| 345 | .split(platform === "win32" ? /\\+/ : /\/+/) | ||
| 346 | .filter((part) => part.length > 0); | ||
| 347 | return parts.at(-1) ?? normalized; | ||
| 348 | } | ||
| 349 | |||
| 350 | function joinWindowsPath(...parts: ReadonlyArray<string>): string { | ||
| 351 | return parts | ||
| 352 | .map((part, index) => { | ||
| 353 | if (index === 0) return part.replace(/[\\/]+$/g, ""); | ||
| 354 | return part.replace(/^[\\/]+|[\\/]+$/g, ""); | ||
| 355 | }) | ||
| 356 | .filter((part) => part.length > 0) | ||
| 357 | .join("\\"); | ||
| 358 | } | ||
| 359 | |||
| 360 | function shellCandidateFromCommand( | ||
| 361 | command: string | null, | ||
| 362 | platform: NodeJS.Platform, | ||
| 363 | ): ShellCandidate | null { | ||
| 364 | if (!command || command.length === 0) return null; | ||
| 365 | const shellName = basenameForPlatform(command, platform).toLowerCase(); | ||
| 366 | if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { | ||
| 367 | return { shell: command, args: ["-NoLogo"] }; | ||
| 368 | } | ||
| 369 | if (platform !== "win32" && shellName === "zsh") { | ||
| 370 | return { shell: command, args: ["-o", "nopromptsp"] }; | ||
| 371 | } | ||
| 372 | return { shell: command }; | ||
| 373 | } | ||
| 374 | |||
| 375 | function windowsSystemRoot(env: NodeJS.ProcessEnv): string { | ||
| 376 | return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; | ||
| 377 | } | ||
| 378 | |||
| 379 | function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { | ||
| 380 | return joinWindowsPath( | ||
| 381 | windowsSystemRoot(env), | ||
| 382 | "System32", | ||
| 383 | "WindowsPowerShell", | ||
| 384 | "v1.0", | ||
| 385 | "powershell.exe", | ||
| 386 | ); | ||
| 387 | } | ||
| 388 | |||
| 389 | function windowsCmdPath(env: NodeJS.ProcessEnv): string { | ||
| 390 | return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); | ||
| 391 | } | ||
| 392 | |||
| 393 | function formatShellCandidate(candidate: ShellCandidate): string { | ||
| 394 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | ||
| 395 | return `${candidate.shell} ${candidate.args.join(" ")}`; | ||
| 396 | } | ||
| 397 | |||
| 398 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | ||
| 399 | const seen = new Set<string>(); | ||
| 400 | const ordered: ShellCandidate[] = []; | ||
| 401 | for (const candidate of candidates) { | ||
| 402 | if (!candidate) continue; | ||
| 403 | const key = formatShellCandidate(candidate); | ||
| 404 | if (seen.has(key)) continue; | ||
| 405 | seen.add(key); | ||
| 406 | ordered.push(candidate); | ||
| 407 | } | ||
| 408 | return ordered; | ||
| 409 | } | ||
| 410 | |||
| 411 | function resolveShellCandidates( | ||
| 412 | shellResolver: () => string, | ||
| 413 | platform: NodeJS.Platform, | ||
| 414 | env: NodeJS.ProcessEnv, | ||
| 415 | ): ShellCandidate[] { | ||
| 416 | const requested = shellCandidateFromCommand( | ||
| 417 | normalizeShellCommand(shellResolver(), platform), | ||
| 418 | platform, | ||
| 419 | ); | ||
| 420 | |||
| 421 | if (platform === "win32") { | ||
| 422 | return uniqueShellCandidates([ | ||
| 423 | requested, | ||
| 424 | shellCandidateFromCommand("pwsh.exe", platform), | ||
| 425 | shellCandidateFromCommand(windowsPowerShellPath(env), platform), | ||
| 426 | shellCandidateFromCommand("powershell.exe", platform), | ||
| 427 | shellCandidateFromCommand(env.ComSpec ?? null, platform), | ||
| 428 | shellCandidateFromCommand(windowsCmdPath(env), platform), | ||
| 429 | shellCandidateFromCommand("cmd.exe", platform), | ||
| 430 | ]); | ||
| 431 | } | ||
| 432 | |||
| 433 | return uniqueShellCandidates([ | ||
| 434 | requested, | ||
| 435 | shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), | ||
| 436 | shellCandidateFromCommand("/bin/zsh", platform), | ||
| 437 | shellCandidateFromCommand("/bin/bash", platform), | ||
| 438 | shellCandidateFromCommand("/bin/sh", platform), | ||
| 439 | shellCandidateFromCommand("zsh", platform), | ||
| 440 | shellCandidateFromCommand("bash", platform), | ||
| 441 | shellCandidateFromCommand("sh", platform), | ||
| 442 | ]); | ||
| 443 | } | ||
| 444 | |||
| 445 | function isRetryableShellSpawnError(error: PtySpawnError): boolean { | ||
| 446 | const queue: unknown[] = [error]; | ||
| 447 | const seen = new Set<unknown>(); | ||
| 448 | const messages: string[] = []; | ||
| 449 | |||
| 450 | while (queue.length > 0) { | ||
| 451 | const current = queue.shift(); | ||
| 452 | if (!current || seen.has(current)) { | ||
| 453 | continue; | ||
| 454 | } | ||
| 455 | seen.add(current); | ||
| 456 | |||
| 457 | if (typeof current === "string") { | ||
| 458 | messages.push(current); | ||
| 459 | continue; | ||
| 460 | } | ||
| 461 | |||
| 462 | if (current instanceof Error) { | ||
| 463 | messages.push(current.message); | ||
| 464 | if (current.cause) { | ||
| 465 | queue.push(current.cause); | ||
| 466 | } | ||
| 467 | continue; | ||
| 468 | } | ||
| 469 | |||
| 470 | if (typeof current === "object") { | ||
| 471 | const value = current as { message?: unknown; cause?: unknown }; | ||
| 472 | if (typeof value.message === "string") { | ||
| 473 | messages.push(value.message); | ||
| 474 | } | ||
| 475 | if (value.cause) { | ||
| 476 | queue.push(value.cause); | ||
| 477 | } | ||
| 478 | } | ||
| 479 | } | ||
| 480 | |||
| 481 | const message = messages.join(" ").toLowerCase(); | ||
| 482 | return ( | ||
| 483 | message.includes("posix_spawnp failed") || | ||
| 484 | message.includes("enoent") || | ||
| 485 | message.includes("not found") || | ||
| 486 | message.includes("file not found") || | ||
| 487 | message.includes("no such file") | ||
| 488 | ); | ||
| 489 | } | ||
| 490 | |||
| 491 | function parseFirstChildPidFromPgrep(stdout: string): number | null { | ||
| 492 | for (const line of stdout.split(/\r?\n/g)) { | ||
| 493 | const n = Number.parseInt(line.trim(), 10); | ||
| 494 | if (Number.isInteger(n) && n > 0) { | ||
| 495 | return n; | ||
| 496 | } | ||
| 497 | } | ||
| 498 | return null; | ||
| 499 | } | ||
| 500 | |||
| 501 | function windowsInspectSubprocess( | ||
| 502 | terminalPid: number, | ||
| 503 | platform: NodeJS.Platform, | ||
| 504 | ): Effect.Effect< | ||
| 505 | TerminalSubprocessInspectResult, | ||
| 506 | TerminalSubprocessCheckError, | ||
| 507 | ProcessRunner.ProcessRunner | ||
| 508 | > { | ||
| 509 | const command = | ||
| 510 | 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; | ||
| 511 | return Effect.gen(function* () { | ||
| 512 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 513 | return yield* processRunner.run({ | ||
| 514 | // powershell.exe is a real executable — never spawn it through cmd.exe | ||
| 515 | // shell mode, which would re-tokenize the `-Command` payload (pipes, | ||
| 516 | // semicolons) before PowerShell ever sees it. | ||
| 517 | command: "powershell.exe", | ||
| 518 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | ||
| 519 | timeout: "1500 millis", | ||
| 520 | maxOutputBytes: 32_768, | ||
| 521 | outputMode: "truncate", | ||
| 522 | timeoutBehavior: "timedOutResult", | ||
| 523 | }); | ||
| 524 | }).pipe( | ||
| 525 | Effect.map((result) => { | ||
| 526 | if (result.code !== 0) { | ||
| 527 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; | ||
| 528 | } | ||
| 529 | const processNameById = new Map<number, string>(); | ||
| 530 | const childrenByParent = new Map<number, number[]>(); | ||
| 531 | for (const line of result.stdout.split(/\r?\n/g)) { | ||
| 532 | const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); | ||
| 533 | const pid = Number(pidRaw); | ||
| 534 | const parentPid = Number(parentPidRaw); | ||
| 535 | if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; | ||
| 536 | processNameById.set(pid, nameRaw?.trim() ?? ""); | ||
| 537 | const children = childrenByParent.get(parentPid) ?? []; | ||
| 538 | children.push(pid); | ||
| 539 | childrenByParent.set(parentPid, children); | ||
| 540 | } | ||
| 541 | const directChildren = childrenByParent.get(terminalPid) ?? []; | ||
| 542 | const childPid = directChildren[0]; | ||
| 543 | if (childPid === undefined) { | ||
| 544 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; | ||
| 545 | } | ||
| 546 | const processIds = new Set<number>([terminalPid]); | ||
| 547 | const pending = [terminalPid]; | ||
| 548 | while (pending.length > 0) { | ||
| 549 | const parentPid = pending.pop(); | ||
| 550 | if (parentPid === undefined) continue; | ||
| 551 | for (const pid of childrenByParent.get(parentPid) ?? []) { | ||
| 552 | if (processIds.has(pid)) continue; | ||
| 553 | processIds.add(pid); | ||
| 554 | pending.push(pid); | ||
| 555 | } | ||
| 556 | } | ||
| 557 | const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); | ||
| 558 | return { | ||
| 559 | hasRunningSubprocess: true, | ||
| 560 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 561 | processIds: [...processIds], | ||
| 562 | } as const; | ||
| 563 | }), | ||
| 564 | Effect.mapError( | ||
| 565 | (cause) => | ||
| 566 | new TerminalSubprocessCheckError({ | ||
| 567 | message: "Failed to inspect Windows terminal subprocesses.", | ||
| 568 | cause, | ||
| 569 | terminalPid, | ||
| 570 | command: "powershell", | ||
| 571 | }), | ||
| 572 | ), | ||
| 573 | ); | ||
| 574 | } | ||
| 575 | |||
| 576 | const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( | ||
| 577 | terminalPid: number, | ||
| 578 | platform: NodeJS.Platform, | ||
| 579 | ): Effect.fn.Return< | ||
| 580 | TerminalSubprocessInspectResult, | ||
| 581 | TerminalSubprocessCheckError, | ||
| 582 | ProcessRunner.ProcessRunner | ||
| 583 | > { | ||
| 584 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 585 | const runPgrep = processRunner | ||
| 586 | .run({ | ||
| 587 | command: "pgrep", | ||
| 588 | args: ["-P", String(terminalPid)], | ||
| 589 | timeout: "1 second", | ||
| 590 | maxOutputBytes: 32_768, | ||
| 591 | outputMode: "truncate", | ||
| 592 | timeoutBehavior: "timedOutResult", | ||
| 593 | }) | ||
| 594 | .pipe( | ||
| 595 | Effect.mapError( | ||
| 596 | (cause) => | ||
| 597 | new TerminalSubprocessCheckError({ | ||
| 598 | message: "Failed to inspect terminal subprocesses with pgrep.", | ||
| 599 | cause, | ||
| 600 | terminalPid, | ||
| 601 | command: "pgrep", | ||
| 602 | }), | ||
| 603 | ), | ||
| 604 | ); | ||
| 605 | |||
| 606 | const runPs = processRunner | ||
| 607 | .run({ | ||
| 608 | command: "ps", | ||
| 609 | args: ["-eo", "pid=,ppid="], | ||
| 610 | timeout: "1 second", | ||
| 611 | maxOutputBytes: 262_144, | ||
| 612 | outputMode: "truncate", | ||
| 613 | timeoutBehavior: "timedOutResult", | ||
| 614 | }) | ||
| 615 | .pipe( | ||
| 616 | Effect.mapError( | ||
| 617 | (cause) => | ||
| 618 | new TerminalSubprocessCheckError({ | ||
| 619 | message: "Failed to inspect terminal subprocesses with ps.", | ||
| 620 | cause, | ||
| 621 | terminalPid, | ||
| 622 | command: "ps", | ||
| 623 | }), | ||
| 624 | ), | ||
| 625 | ); | ||
| 626 | |||
| 627 | let childPid: number | null = null; | ||
| 628 | |||
| 629 | const pgrepResult = yield* Effect.exit(runPgrep); | ||
| 630 | if (pgrepResult._tag === "Success") { | ||
| 631 | if (pgrepResult.value.code === 0) { | ||
| 632 | childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); | ||
| 633 | } else if (pgrepResult.value.code === 1) { | ||
| 634 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 635 | } | ||
| 636 | } | ||
| 637 | |||
| 638 | if (childPid === null) { | ||
| 639 | const psResult = yield* Effect.exit(runPs); | ||
| 640 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { | ||
| 641 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 642 | } | ||
| 643 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | ||
| 644 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | ||
| 645 | const pid = Number(pidRaw); | ||
| 646 | const ppid = Number(ppidRaw); | ||
| 647 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 648 | if (ppid === terminalPid) { | ||
| 649 | childPid = pid; | ||
| 650 | break; | ||
| 651 | } | ||
| 652 | } | ||
| 653 | } | ||
| 654 | |||
| 655 | if (childPid === null) { | ||
| 656 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 657 | } | ||
| 658 | |||
| 659 | const runComm = processRunner.run({ | ||
| 660 | command: "ps", | ||
| 661 | args: ["-p", String(childPid), "-o", "comm="], | ||
| 662 | timeout: "1 second", | ||
| 663 | maxOutputBytes: 8_192, | ||
| 664 | outputMode: "truncate", | ||
| 665 | timeoutBehavior: "timedOutResult", | ||
| 666 | }); | ||
| 667 | |||
| 668 | const commResult = yield* Effect.exit(runComm); | ||
| 669 | let rawComm: string | null = null; | ||
| 670 | if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { | ||
| 671 | rawComm = commResult.value.stdout.trim(); | ||
| 672 | } | ||
| 673 | |||
| 674 | if (!rawComm || rawComm.length === 0) { | ||
| 675 | const runArgs = processRunner.run({ | ||
| 676 | command: "ps", | ||
| 677 | args: ["-p", String(childPid), "-o", "args="], | ||
| 678 | timeout: "1 second", | ||
| 679 | maxOutputBytes: 16_384, | ||
| 680 | outputMode: "truncate", | ||
| 681 | timeoutBehavior: "timedOutResult", | ||
| 682 | }); | ||
| 683 | const argsResult = yield* Effect.exit(runArgs); | ||
| 684 | if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { | ||
| 685 | const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; | ||
| 686 | rawComm = first.length > 0 ? first : null; | ||
| 687 | } | ||
| 688 | } | ||
| 689 | |||
| 690 | const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; | ||
| 691 | const processIds = new Set<number>([terminalPid]); | ||
| 692 | const psResult = yield* Effect.exit(runPs); | ||
| 693 | if (psResult._tag === "Success" && psResult.value.code === 0) { | ||
| 694 | const childrenByParent = new Map<number, number[]>(); | ||
| 695 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | ||
| 696 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | ||
| 697 | const pid = Number(pidRaw); | ||
| 698 | const ppid = Number(ppidRaw); | ||
| 699 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 700 | const children = childrenByParent.get(ppid) ?? []; | ||
| 701 | children.push(pid); | ||
| 702 | childrenByParent.set(ppid, children); | ||
| 703 | } | ||
| 704 | const pending = [terminalPid]; | ||
| 705 | while (pending.length > 0) { | ||
| 706 | const parentPid = pending.pop(); | ||
| 707 | if (parentPid === undefined) continue; | ||
| 708 | for (const child of childrenByParent.get(parentPid) ?? []) { | ||
| 709 | if (processIds.has(child)) continue; | ||
| 710 | processIds.add(child); | ||
| 711 | pending.push(child); | ||
| 712 | } | ||
| 713 | } | ||
| 714 | } else { | ||
| 715 | processIds.add(childPid); | ||
| 716 | } | ||
| 717 | return { | ||
| 718 | hasRunningSubprocess: true, | ||
| 719 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 720 | processIds: [...processIds], | ||
| 721 | }; | ||
| 722 | }); | ||
| 723 | |||
| 724 | function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { | ||
| 725 | return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { | ||
| 726 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | ||
| 727 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 728 | } | ||
| 729 | if (platform === "win32") { | ||
| 730 | return yield* windowsInspectSubprocess(terminalPid, platform); | ||
| 731 | } | ||
| 732 | return yield* posixInspectSubprocess(terminalPid, platform); | ||
| 733 | }); | ||
| 734 | } | ||
| 735 | |||
| 736 | function capHistory(history: string, maxLines: number): string { | ||
| 737 | if (history.length === 0) return history; | ||
| 738 | const hasTrailingNewline = history.endsWith("\n"); | ||
| 739 | const lines = history.split("\n"); | ||
| 740 | if (hasTrailingNewline) { | ||
| 741 | lines.pop(); | ||
| 742 | } | ||
| 743 | if (lines.length <= maxLines) return history; | ||
| 744 | const capped = lines.slice(lines.length - maxLines).join("\n"); | ||
| 745 | return hasTrailingNewline ? `${capped}\n` : capped; | ||
| 746 | } | ||
| 747 | |||
| 748 | function isCsiFinalByte(codePoint: number): boolean { | ||
| 749 | return codePoint >= 0x40 && codePoint <= 0x7e; | ||
| 750 | } | ||
| 751 | |||
| 752 | function shouldStripCsiSequence(body: string, finalByte: string): boolean { | ||
| 753 | if (finalByte === "n") { | ||
| 754 | return true; | ||
| 755 | } | ||
| 756 | if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { | ||
| 757 | return true; | ||
| 758 | } | ||
| 759 | if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { | ||
| 760 | return true; | ||
| 761 | } | ||
| 762 | return false; | ||
| 763 | } | ||
| 764 | |||
| 765 | function shouldStripOscSequence(content: string): boolean { | ||
| 766 | return /^(10|11|12);(?:\?|rgb:)/.test(content); | ||
| 767 | } | ||
| 768 | |||
| 769 | function stripStringTerminator(value: string): string { | ||
| 770 | if (value.endsWith("\u001b\\")) { | ||
| 771 | return value.slice(0, -2); | ||
| 772 | } | ||
| 773 | const lastCharacter = value.at(-1); | ||
| 774 | if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { | ||
| 775 | return value.slice(0, -1); | ||
| 776 | } | ||
| 777 | return value; | ||
| 778 | } | ||
| 779 | |||
| 780 | function findStringTerminatorIndex(input: string, start: number): number | null { | ||
| 781 | for (let index = start; index < input.length; index += 1) { | ||
| 782 | const codePoint = input.charCodeAt(index); | ||
| 783 | if (codePoint === 0x07 || codePoint === 0x9c) { | ||
| 784 | return index + 1; | ||
| 785 | } | ||
| 786 | if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { | ||
| 787 | return index + 2; | ||
| 788 | } | ||
| 789 | } | ||
| 790 | return null; | ||
| 791 | } | ||
| 792 | |||
| 793 | function isEscapeIntermediateByte(codePoint: number): boolean { | ||
| 794 | return codePoint >= 0x20 && codePoint <= 0x2f; | ||
| 795 | } | ||
| 796 | |||
| 797 | function isEscapeFinalByte(codePoint: number): boolean { | ||
| 798 | return codePoint >= 0x30 && codePoint <= 0x7e; | ||
| 799 | } | ||
| 800 | |||
| 801 | function findEscapeSequenceEndIndex(input: string, start: number): number | null { | ||
| 802 | let cursor = start; | ||
| 803 | while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { | ||
| 804 | cursor += 1; | ||
| 805 | } | ||
| 806 | if (cursor >= input.length) { | ||
| 807 | return null; | ||
| 808 | } | ||
| 809 | return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; | ||
| 810 | } | ||
| 811 | |||
| 812 | function sanitizeTerminalHistoryChunk( | ||
| 813 | pendingControlSequence: string, | ||
| 814 | data: string, | ||
| 815 | ): { visibleText: string; pendingControlSequence: string } { | ||
| 816 | const input = `${pendingControlSequence}${data}`; | ||
| 817 | let visibleText = ""; | ||
| 818 | let index = 0; | ||
| 819 | |||
| 820 | const append = (value: string) => { | ||
| 821 | visibleText += value; | ||
| 822 | }; | ||
| 823 | |||
| 824 | while (index < input.length) { | ||
| 825 | const codePoint = input.charCodeAt(index); | ||
| 826 | |||
| 827 | if (codePoint === 0x1b) { | ||
| 828 | const nextCodePoint = input.charCodeAt(index + 1); | ||
| 829 | if (Number.isNaN(nextCodePoint)) { | ||
| 830 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 831 | } | ||
| 832 | |||
| 833 | if (nextCodePoint === 0x5b) { | ||
| 834 | let cursor = index + 2; | ||
| 835 | while (cursor < input.length) { | ||
| 836 | if (isCsiFinalByte(input.charCodeAt(cursor))) { | ||
| 837 | const sequence = input.slice(index, cursor + 1); | ||
| 838 | const body = input.slice(index + 2, cursor); | ||
| 839 | if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { | ||
| 840 | append(sequence); | ||
| 841 | } | ||
| 842 | index = cursor + 1; | ||
| 843 | break; | ||
| 844 | } | ||
| 845 | cursor += 1; | ||
| 846 | } | ||
| 847 | if (cursor >= input.length) { | ||
| 848 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 849 | } | ||
| 850 | continue; | ||
| 851 | } | ||
| 852 | |||
| 853 | if ( | ||
| 854 | nextCodePoint === 0x5d || | ||
| 855 | nextCodePoint === 0x50 || | ||
| 856 | nextCodePoint === 0x5e || | ||
| 857 | nextCodePoint === 0x5f | ||
| 858 | ) { | ||
| 859 | const terminatorIndex = findStringTerminatorIndex(input, index + 2); | ||
| 860 | if (terminatorIndex === null) { | ||
| 861 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 862 | } | ||
| 863 | const sequence = input.slice(index, terminatorIndex); | ||
| 864 | const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); | ||
| 865 | if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { | ||
| 866 | append(sequence); | ||
| 867 | } | ||
| 868 | index = terminatorIndex; | ||
| 869 | continue; | ||
| 870 | } | ||
| 871 | |||
| 872 | const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); | ||
| 873 | if (escapeSequenceEndIndex === null) { | ||
| 874 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 875 | } | ||
| 876 | append(input.slice(index, escapeSequenceEndIndex)); | ||
| 877 | index = escapeSequenceEndIndex; | ||
| 878 | continue; | ||
| 879 | } | ||
| 880 | |||
| 881 | if (codePoint === 0x9b) { | ||
| 882 | let cursor = index + 1; | ||
| 883 | while (cursor < input.length) { | ||
| 884 | if (isCsiFinalByte(input.charCodeAt(cursor))) { | ||
| 885 | const sequence = input.slice(index, cursor + 1); | ||
| 886 | const body = input.slice(index + 1, cursor); | ||
| 887 | if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { | ||
| 888 | append(sequence); | ||
| 889 | } | ||
| 890 | index = cursor + 1; | ||
| 891 | break; | ||
| 892 | } | ||
| 893 | cursor += 1; | ||
| 894 | } | ||
| 895 | if (cursor >= input.length) { | ||
| 896 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 897 | } | ||
| 898 | continue; | ||
| 899 | } | ||
| 900 | |||
| 901 | if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { | ||
| 902 | const terminatorIndex = findStringTerminatorIndex(input, index + 1); | ||
| 903 | if (terminatorIndex === null) { | ||
| 904 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 905 | } | ||
| 906 | const sequence = input.slice(index, terminatorIndex); | ||
| 907 | const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); | ||
| 908 | if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { | ||
| 909 | append(sequence); | ||
| 910 | } | ||
| 911 | index = terminatorIndex; | ||
| 912 | continue; | ||
| 913 | } | ||
| 914 | |||
| 915 | append(input[index] ?? ""); | ||
| 916 | index += 1; | ||
| 917 | } | ||
| 918 | |||
| 919 | return { visibleText, pendingControlSequence: "" }; | ||
| 920 | } | ||
| 921 | |||
| 922 | function legacySafeThreadId(threadId: string): string { | ||
| 923 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | ||
| 924 | } | ||
| 925 | |||
| 926 | function toSafeThreadId(threadId: string): string { | ||
| 927 | return `terminal_${Encoding.encodeBase64Url(threadId)}`; | ||
| 928 | } | ||
| 929 | |||
| 930 | function toSafeTerminalId(terminalId: string): string { | ||
| 931 | return Encoding.encodeBase64Url(terminalId); | ||
| 932 | } | ||
| 933 | |||
| 934 | function toSessionKey(threadId: string, terminalId: string): string { | ||
| 935 | return `${threadId}\u0000${terminalId}`; | ||
| 936 | } | ||
| 937 | |||
| 938 | function shouldExcludeTerminalEnvKey(key: string): boolean { | ||
| 939 | const normalizedKey = key.toUpperCase(); | ||
| 940 | if (normalizedKey.startsWith("T3CODE_")) { | ||
| 941 | return true; | ||
| 942 | } | ||
| 943 | if (normalizedKey.startsWith("VITE_")) { | ||
| 944 | return true; | ||
| 945 | } | ||
| 946 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); | ||
| 947 | } | ||
| 948 | |||
| 949 | function createTerminalSpawnEnv( | ||
| 950 | baseEnv: NodeJS.ProcessEnv, | ||
| 951 | runtimeEnv?: Record<string, string> | null, | ||
| 952 | ): NodeJS.ProcessEnv { | ||
| 953 | const spawnEnv: NodeJS.ProcessEnv = {}; | ||
| 954 | for (const [key, value] of Object.entries(baseEnv)) { | ||
| 955 | if (value === undefined) continue; | ||
| 956 | if (shouldExcludeTerminalEnvKey(key)) continue; | ||
| 957 | spawnEnv[key] = value; | ||
| 958 | } | ||
| 959 | if (runtimeEnv) { | ||
| 960 | for (const [key, value] of Object.entries(runtimeEnv)) { | ||
| 961 | spawnEnv[key] = value; | ||
| 962 | } | ||
| 963 | } | ||
| 964 | return spawnEnv; | ||
| 965 | } | ||
| 966 | |||
| 967 | function normalizedRuntimeEnv( | ||
| 968 | env: Record<string, string> | undefined, | ||
| 969 | ): Record<string, string> | null { | ||
| 970 | if (!env) return null; | ||
| 971 | const entries = Object.entries(env); | ||
| 972 | if (entries.length === 0) return null; | ||
| 973 | return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); | ||
| 974 | } | ||
| 975 | |||
| 976 | interface TerminalManagerOptions { | ||
| 977 | logsDir: string; | ||
| 978 | historyLineLimit?: number; | ||
| 979 | ptyAdapter: PtyAdapterShape; | ||
| 980 | shellResolver?: () => string; | ||
| 981 | env?: NodeJS.ProcessEnv; | ||
| 982 | subprocessInspector?: TerminalSubprocessInspector; | ||
| 983 | subprocessPollIntervalMs?: number; | ||
| 984 | processKillGraceMs?: number; | ||
| 985 | maxRetainedInactiveSessions?: number; | ||
| 986 | registerTerminalProcesses?: (input: { | ||
| 987 | readonly threadId: string; | ||
| 988 | readonly terminalId: string; | ||
| 989 | readonly processIds: ReadonlyArray<number>; | ||
| 990 | }) => Effect.Effect<void>; | ||
| 991 | unregisterTerminal?: (input: { | ||
| 992 | readonly threadId: string; | ||
| 993 | readonly terminalId: string; | ||
| 994 | }) => Effect.Effect<void>; | ||
| 995 | } | ||
| 996 | |||
| 997 | const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { | ||
| 998 | const { terminalLogsDir } = yield* ServerConfig; | ||
| 999 | const ptyAdapter = yield* PtyAdapter; | ||
| 1000 | const portDiscovery = yield* PortScanner.PortDiscovery; | ||
| 1001 | return yield* makeTerminalManagerWithOptions({ | ||
| 1002 | logsDir: terminalLogsDir, | ||
| 1003 | ptyAdapter, | ||
| 1004 | registerTerminalProcesses: portDiscovery.registerTerminalProcesses, | ||
| 1005 | unregisterTerminal: portDiscovery.unregisterTerminal, | ||
| 1006 | }); | ||
| 1007 | }); | ||
| 1008 | |||
| 1009 | export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( | ||
| 1010 | function* (options: TerminalManagerOptions) { | ||
| 1011 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1012 | const path = yield* Path.Path; | ||
| 1013 | const context = yield* Effect.context<never>(); | ||
| 1014 | const runFork = Effect.runForkWith(context); | ||
| 1015 | |||
| 1016 | const logsDir = options.logsDir; | ||
| 1017 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | ||
| 1018 | const platform = yield* HostProcessPlatform; | ||
| 1019 | // Terminals must inherit the user's full environment (minus the blocklist | ||
| 1020 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips | ||
| 1021 | // things like PSModulePath, DISPLAY, proxies, and toolchain variables. | ||
| 1022 | // `options.env` is the test seam. | ||
| 1023 | const baseEnv = options.env ?? process.env; | ||
| 1024 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); | ||
| 1025 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 1026 | const subprocessInspector = | ||
| 1027 | options.subprocessInspector ?? | ||
| 1028 | ((terminalPid) => | ||
| 1029 | defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( | ||
| 1030 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 1031 | )); | ||
| 1032 | const subprocessPollIntervalMs = | ||
| 1033 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | ||
| 1034 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; | ||
| 1035 | const maxRetainedInactiveSessions = | ||
| 1036 | options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; | ||
| 1037 | const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); | ||
| 1038 | const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); | ||
| 1039 | |||
| 1040 | yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); | ||
| 1041 | |||
| 1042 | const managerStateRef = yield* SynchronizedRef.make<TerminalManagerState>({ | ||
| 1043 | sessions: new Map(), | ||
| 1044 | killFibers: new Map(), | ||
| 1045 | }); | ||
| 1046 | const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>()); | ||
| 1047 | const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>(); | ||
| 1048 | const workerScope = yield* Scope.make("sequential"); | ||
| 1049 | yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); | ||
| 1050 | |||
| 1051 | const publishEvent = (event: TerminalEvent) => | ||
| 1052 | Effect.gen(function* () { | ||
| 1053 | for (const listener of terminalEventListeners) { | ||
| 1054 | yield* listener(event).pipe(Effect.ignoreCause({ log: true })); | ||
| 1055 | } | ||
| 1056 | }); | ||
| 1057 | |||
| 1058 | const historyPath = (threadId: string, terminalId: string) => { | ||
| 1059 | const threadPart = toSafeThreadId(threadId); | ||
| 1060 | if (terminalId === DEFAULT_TERMINAL_ID) { | ||
| 1061 | return path.join(logsDir, `${threadPart}.log`); | ||
| 1062 | } | ||
| 1063 | return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); | ||
| 1064 | }; | ||
| 1065 | |||
| 1066 | const legacyHistoryPath = (threadId: string) => | ||
| 1067 | path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); | ||
| 1068 | |||
| 1069 | const toTerminalHistoryError = | ||
| 1070 | (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => | ||
| 1071 | (cause: unknown) => | ||
| 1072 | new TerminalHistoryError({ | ||
| 1073 | operation, | ||
| 1074 | threadId, | ||
| 1075 | terminalId, | ||
| 1076 | cause, | ||
| 1077 | }); | ||
| 1078 | |||
| 1079 | const readManagerState = SynchronizedRef.get(managerStateRef); | ||
| 1080 | |||
| 1081 | const modifyManagerState = <A>( | ||
| 1082 | f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], | ||
| 1083 | ) => SynchronizedRef.modify(managerStateRef, f); | ||
| 1084 | |||
| 1085 | const getThreadSemaphore = (threadId: string) => | ||
| 1086 | SynchronizedRef.modifyEffect(threadLocksRef, (current) => { | ||
| 1087 | const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr( | ||
| 1088 | current.get(threadId), | ||
| 1089 | ); | ||
| 1090 | return Option.match(existing, { | ||
| 1091 | onNone: () => | ||
| 1092 | Semaphore.make(1).pipe( | ||
| 1093 | Effect.map((semaphore) => { | ||
| 1094 | const next = new Map(current); | ||
| 1095 | next.set(threadId, semaphore); | ||
| 1096 | return [semaphore, next] as const; | ||
| 1097 | }), | ||
| 1098 | ), | ||
| 1099 | onSome: (semaphore) => Effect.succeed([semaphore, current] as const), | ||
| 1100 | }); | ||
| 1101 | }); | ||
| 1102 | |||
| 1103 | const withThreadLock = <A, E, R>( | ||
| 1104 | threadId: string, | ||
| 1105 | effect: Effect.Effect<A, E, R>, | ||
| 1106 | ): Effect.Effect<A, E, R> => | ||
| 1107 | Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); | ||
| 1108 | |||
| 1109 | const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( | ||
| 1110 | process: PtyProcess | null, | ||
| 1111 | ) { | ||
| 1112 | if (!process) return; | ||
| 1113 | const fiber: Option.Option<Fiber.Fiber<void, never>> = yield* modifyManagerState< | ||
| 1114 | Option.Option<Fiber.Fiber<void, never>> | ||
| 1115 | >((state) => { | ||
| 1116 | const existing: Option.Option<Fiber.Fiber<void, never>> = Option.fromNullishOr( | ||
| 1117 | state.killFibers.get(process), | ||
| 1118 | ); | ||
| 1119 | if (Option.isNone(existing)) { | ||
| 1120 | return [Option.none<Fiber.Fiber<void, never>>(), state] as const; | ||
| 1121 | } | ||
| 1122 | const killFibers = new Map(state.killFibers); | ||
| 1123 | killFibers.delete(process); | ||
| 1124 | return [existing, { ...state, killFibers }] as const; | ||
| 1125 | }); | ||
| 1126 | if (Option.isSome(fiber)) { | ||
| 1127 | yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore); | ||
| 1128 | } | ||
| 1129 | }); | ||
| 1130 | |||
| 1131 | const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* ( | ||
| 1132 | process: PtyProcess, | ||
| 1133 | fiber: Fiber.Fiber<void, never>, | ||
| 1134 | ) { | ||
| 1135 | yield* modifyManagerState((state) => { | ||
| 1136 | const killFibers = new Map(state.killFibers); | ||
| 1137 | killFibers.set(process, fiber); | ||
| 1138 | return [undefined, { ...state, killFibers }] as const; | ||
| 1139 | }); | ||
| 1140 | }); | ||
| 1141 | |||
| 1142 | const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( | ||
| 1143 | process: PtyProcess, | ||
| 1144 | threadId: string, | ||
| 1145 | terminalId: string, | ||
| 1146 | ) { | ||
| 1147 | const terminated = yield* Effect.try({ | ||
| 1148 | try: () => process.kill("SIGTERM"), | ||
| 1149 | catch: (cause) => | ||
| 1150 | new TerminalProcessSignalError({ | ||
| 1151 | message: "Failed to send SIGTERM to terminal process.", | ||
| 1152 | cause, | ||
| 1153 | signal: "SIGTERM", | ||
| 1154 | }), | ||
| 1155 | }).pipe( | ||
| 1156 | Effect.as(true), | ||
| 1157 | Effect.catch((error) => | ||
| 1158 | Effect.logWarning("failed to kill terminal process", { | ||
| 1159 | threadId, | ||
| 1160 | terminalId, | ||
| 1161 | signal: "SIGTERM", | ||
| 1162 | error: error.message, | ||
| 1163 | }).pipe(Effect.as(false)), | ||
| 1164 | ), | ||
| 1165 | ); | ||
| 1166 | if (!terminated) { | ||
| 1167 | return; | ||
| 1168 | } | ||
| 1169 | |||
| 1170 | yield* Effect.sleep(processKillGraceMs); | ||
| 1171 | |||
| 1172 | yield* Effect.try({ | ||
| 1173 | try: () => process.kill("SIGKILL"), | ||
| 1174 | catch: (cause) => | ||
| 1175 | new TerminalProcessSignalError({ | ||
| 1176 | message: "Failed to send SIGKILL to terminal process.", | ||
| 1177 | cause, | ||
| 1178 | signal: "SIGKILL", | ||
| 1179 | }), | ||
| 1180 | }).pipe( | ||
| 1181 | Effect.catch((error) => | ||
| 1182 | Effect.logWarning("failed to force-kill terminal process", { | ||
| 1183 | threadId, | ||
| 1184 | terminalId, | ||
| 1185 | signal: "SIGKILL", | ||
| 1186 | error: error.message, | ||
| 1187 | }), | ||
| 1188 | ), | ||
| 1189 | ); | ||
| 1190 | }); | ||
| 1191 | |||
| 1192 | const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( | ||
| 1193 | process: PtyProcess, | ||
| 1194 | threadId: string, | ||
| 1195 | terminalId: string, | ||
| 1196 | ) { | ||
| 1197 | const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( | ||
| 1198 | Effect.ensuring( | ||
| 1199 | modifyManagerState((state) => { | ||
| 1200 | if (!state.killFibers.has(process)) { | ||
| 1201 | return [undefined, state] as const; | ||
| 1202 | } | ||
| 1203 | const killFibers = new Map(state.killFibers); | ||
| 1204 | killFibers.delete(process); | ||
| 1205 | return [undefined, { ...state, killFibers }] as const; | ||
| 1206 | }), | ||
| 1207 | ), | ||
| 1208 | Effect.forkIn(workerScope), | ||
| 1209 | ); | ||
| 1210 | |||
| 1211 | yield* registerKillFiber(process, fiber); | ||
| 1212 | }); | ||
| 1213 | |||
| 1214 | const persistWorker = yield* makeKeyedCoalescingWorker< | ||
| 1215 | string, | ||
| 1216 | PersistHistoryRequest, | ||
| 1217 | never, | ||
| 1218 | never | ||
| 1219 | >({ | ||
| 1220 | merge: (current, next) => ({ | ||
| 1221 | history: next.history, | ||
| 1222 | immediate: current.immediate || next.immediate, | ||
| 1223 | }), | ||
| 1224 | process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { | ||
| 1225 | if (!request.immediate) { | ||
| 1226 | yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); | ||
| 1227 | } | ||
| 1228 | |||
| 1229 | const [threadId, terminalId] = sessionKey.split("\u0000"); | ||
| 1230 | if (!threadId || !terminalId) { | ||
| 1231 | return; | ||
| 1232 | } | ||
| 1233 | |||
| 1234 | yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( | ||
| 1235 | Effect.catch((error) => | ||
| 1236 | Effect.logWarning("failed to persist terminal history", { | ||
| 1237 | threadId, | ||
| 1238 | terminalId, | ||
| 1239 | error, | ||
| 1240 | }), | ||
| 1241 | ), | ||
| 1242 | ); | ||
| 1243 | }), | ||
| 1244 | }); | ||
| 1245 | |||
| 1246 | const queuePersist = Effect.fn("terminal.queuePersist")(function* ( | ||
| 1247 | threadId: string, | ||
| 1248 | terminalId: string, | ||
| 1249 | history: string, | ||
| 1250 | ) { | ||
| 1251 | yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { | ||
| 1252 | history, | ||
| 1253 | immediate: false, | ||
| 1254 | }); | ||
| 1255 | }); | ||
| 1256 | |||
| 1257 | const flushPersist = Effect.fn("terminal.flushPersist")(function* ( | ||
| 1258 | threadId: string, | ||
| 1259 | terminalId: string, | ||
| 1260 | ) { | ||
| 1261 | yield* persistWorker.drainKey(toSessionKey(threadId, terminalId)); | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | const persistHistory = Effect.fn("terminal.persistHistory")(function* ( | ||
| 1265 | threadId: string, | ||
| 1266 | terminalId: string, | ||
| 1267 | history: string, | ||
| 1268 | ) { | ||
| 1269 | yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { | ||
| 1270 | history, | ||
| 1271 | immediate: true, | ||
| 1272 | }); | ||
| 1273 | yield* flushPersist(threadId, terminalId); | ||
| 1274 | }); | ||
| 1275 | |||
| 1276 | const readHistory = Effect.fn("terminal.readHistory")(function* ( | ||
| 1277 | threadId: string, | ||
| 1278 | terminalId: string, | ||
| 1279 | ) { | ||
| 1280 | const nextPath = historyPath(threadId, terminalId); | ||
| 1281 | if ( | ||
| 1282 | yield* fileSystem | ||
| 1283 | .exists(nextPath) | ||
| 1284 | .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) | ||
| 1285 | ) { | ||
| 1286 | const raw = yield* fileSystem | ||
| 1287 | .readFileString(nextPath) | ||
| 1288 | .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); | ||
| 1289 | const capped = capHistory(raw, historyLineLimit); | ||
| 1290 | if (capped !== raw) { | ||
| 1291 | yield* fileSystem | ||
| 1292 | .writeFileString(nextPath, capped) | ||
| 1293 | .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); | ||
| 1294 | } | ||
| 1295 | return capped; | ||
| 1296 | } | ||
| 1297 | |||
| 1298 | if (terminalId !== DEFAULT_TERMINAL_ID) { | ||
| 1299 | return ""; | ||
| 1300 | } | ||
| 1301 | |||
| 1302 | const legacyPath = legacyHistoryPath(threadId); | ||
| 1303 | if ( | ||
| 1304 | !(yield* fileSystem | ||
| 1305 | .exists(legacyPath) | ||
| 1306 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) | ||
| 1307 | ) { | ||
| 1308 | return ""; | ||
| 1309 | } | ||
| 1310 | |||
| 1311 | const raw = yield* fileSystem | ||
| 1312 | .readFileString(legacyPath) | ||
| 1313 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); | ||
| 1314 | const capped = capHistory(raw, historyLineLimit); | ||
| 1315 | yield* fileSystem | ||
| 1316 | .writeFileString(nextPath, capped) | ||
| 1317 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); | ||
| 1318 | yield* fileSystem.remove(legacyPath, { force: true }).pipe( | ||
| 1319 | Effect.catch((cleanupError) => | ||
| 1320 | Effect.logWarning("failed to remove legacy terminal history", { | ||
| 1321 | threadId, | ||
| 1322 | error: cleanupError, | ||
| 1323 | }), | ||
| 1324 | ), | ||
| 1325 | ); | ||
| 1326 | return capped; | ||
| 1327 | }); | ||
| 1328 | |||
| 1329 | const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( | ||
| 1330 | threadId: string, | ||
| 1331 | terminalId: string, | ||
| 1332 | ) { | ||
| 1333 | yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( | ||
| 1334 | Effect.catch((error) => | ||
| 1335 | Effect.logWarning("failed to delete terminal history", { | ||
| 1336 | threadId, | ||
| 1337 | terminalId, | ||
| 1338 | error, | ||
| 1339 | }), | ||
| 1340 | ), | ||
| 1341 | ); | ||
| 1342 | if (terminalId === DEFAULT_TERMINAL_ID) { | ||
| 1343 | yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( | ||
| 1344 | Effect.catch((error) => | ||
| 1345 | Effect.logWarning("failed to delete terminal history", { | ||
| 1346 | threadId, | ||
| 1347 | terminalId, | ||
| 1348 | error, | ||
| 1349 | }), | ||
| 1350 | ), | ||
| 1351 | ); | ||
| 1352 | } | ||
| 1353 | }); | ||
| 1354 | |||
| 1355 | const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( | ||
| 1356 | threadId: string, | ||
| 1357 | ) { | ||
| 1358 | const threadPrefix = `${toSafeThreadId(threadId)}_`; | ||
| 1359 | const entries = yield* fileSystem | ||
| 1360 | .readDirectory(logsDir, { recursive: false }) | ||
| 1361 | .pipe(Effect.orElseSucceed(() => [] as Array<string>)); | ||
| 1362 | yield* Effect.forEach( | ||
| 1363 | entries.filter( | ||
| 1364 | (name) => | ||
| 1365 | name === `${toSafeThreadId(threadId)}.log` || | ||
| 1366 | name === `${legacySafeThreadId(threadId)}.log` || | ||
| 1367 | name.startsWith(threadPrefix), | ||
| 1368 | ), | ||
| 1369 | (name) => | ||
| 1370 | fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( | ||
| 1371 | Effect.catch((error) => | ||
| 1372 | Effect.logWarning("failed to delete terminal histories for thread", { | ||
| 1373 | threadId, | ||
| 1374 | error, | ||
| 1375 | }), | ||
| 1376 | ), | ||
| 1377 | ), | ||
| 1378 | { discard: true }, | ||
| 1379 | ); | ||
| 1380 | }); | ||
| 1381 | |||
| 1382 | const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { | ||
| 1383 | const stats = yield* fileSystem.stat(cwd).pipe( | ||
| 1384 | Effect.mapError( | ||
| 1385 | (cause) => | ||
| 1386 | new TerminalCwdError({ | ||
| 1387 | cwd, | ||
| 1388 | reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", | ||
| 1389 | cause, | ||
| 1390 | }), | ||
| 1391 | ), | ||
| 1392 | ); | ||
| 1393 | if (stats.type !== "Directory") { | ||
| 1394 | return yield* new TerminalCwdError({ | ||
| 1395 | cwd, | ||
| 1396 | reason: "notDirectory", | ||
| 1397 | }); | ||
| 1398 | } | ||
| 1399 | }); | ||
| 1400 | |||
| 1401 | const getSession = Effect.fn("terminal.getSession")(function* ( | ||
| 1402 | threadId: string, | ||
| 1403 | terminalId: string, | ||
| 1404 | ): Effect.fn.Return<Option.Option<TerminalSessionState>> { | ||
| 1405 | return yield* Effect.map(readManagerState, (state) => | ||
| 1406 | Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), | ||
| 1407 | ); | ||
| 1408 | }); | ||
| 1409 | |||
| 1410 | const requireSession = Effect.fn("terminal.requireSession")(function* ( | ||
| 1411 | threadId: string, | ||
| 1412 | terminalId: string, | ||
| 1413 | ): Effect.fn.Return<TerminalSessionState, TerminalSessionLookupError> { | ||
| 1414 | return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => | ||
| 1415 | Option.match(session, { | ||
| 1416 | onNone: () => | ||
| 1417 | Effect.fail( | ||
| 1418 | new TerminalSessionLookupError({ | ||
| 1419 | threadId, | ||
| 1420 | terminalId, | ||
| 1421 | }), | ||
| 1422 | ), | ||
| 1423 | onSome: Effect.succeed, | ||
| 1424 | }), | ||
| 1425 | ); | ||
| 1426 | }); | ||
| 1427 | |||
| 1428 | const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { | ||
| 1429 | return yield* readManagerState.pipe( | ||
| 1430 | Effect.map((state) => | ||
| 1431 | [...state.sessions.values()].filter((session) => session.threadId === threadId), | ||
| 1432 | ), | ||
| 1433 | ); | ||
| 1434 | }); | ||
| 1435 | |||
| 1436 | const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( | ||
| 1437 | function* () { | ||
| 1438 | yield* modifyManagerState((state) => { | ||
| 1439 | const inactiveSessions = [...state.sessions.values()].filter( | ||
| 1440 | (session) => session.status !== "running", | ||
| 1441 | ); | ||
| 1442 | if (inactiveSessions.length <= maxRetainedInactiveSessions) { | ||
| 1443 | return [undefined, state] as const; | ||
| 1444 | } | ||
| 1445 | |||
| 1446 | inactiveSessions.sort( | ||
| 1447 | (left, right) => | ||
| 1448 | left.updatedAt.localeCompare(right.updatedAt) || | ||
| 1449 | left.threadId.localeCompare(right.threadId) || | ||
| 1450 | left.terminalId.localeCompare(right.terminalId), | ||
| 1451 | ); | ||
| 1452 | |||
| 1453 | const sessions = new Map(state.sessions); | ||
| 1454 | |||
| 1455 | const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; | ||
| 1456 | for (const session of inactiveSessions.slice(0, toEvict)) { | ||
| 1457 | const key = toSessionKey(session.threadId, session.terminalId); | ||
| 1458 | sessions.delete(key); | ||
| 1459 | } | ||
| 1460 | |||
| 1461 | return [undefined, { ...state, sessions }] as const; | ||
| 1462 | }); | ||
| 1463 | }, | ||
| 1464 | ); | ||
| 1465 | |||
| 1466 | const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( | ||
| 1467 | session: TerminalSessionState, | ||
| 1468 | expectedPid: number, | ||
| 1469 | ) { | ||
| 1470 | while (true) { | ||
| 1471 | const action: DrainProcessEventAction = yield* Effect.sync(() => { | ||
| 1472 | if (session.pid !== expectedPid || !session.process || session.status !== "running") { | ||
| 1473 | session.pendingProcessEvents = []; | ||
| 1474 | session.pendingProcessEventIndex = 0; | ||
| 1475 | session.processEventDrainRunning = false; | ||
| 1476 | return { type: "idle" } as const; | ||
| 1477 | } | ||
| 1478 | |||
| 1479 | const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; | ||
| 1480 | if (!nextEvent) { | ||
| 1481 | session.pendingProcessEvents = []; | ||
| 1482 | session.pendingProcessEventIndex = 0; | ||
| 1483 | session.processEventDrainRunning = false; | ||
| 1484 | return { type: "idle" } as const; | ||
| 1485 | } | ||
| 1486 | |||
| 1487 | session.pendingProcessEventIndex += 1; | ||
| 1488 | if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { | ||
| 1489 | session.pendingProcessEvents = []; | ||
| 1490 | session.pendingProcessEventIndex = 0; | ||
| 1491 | } | ||
| 1492 | |||
| 1493 | if (nextEvent.type === "output") { | ||
| 1494 | const sanitized = sanitizeTerminalHistoryChunk( | ||
| 1495 | session.pendingHistoryControlSequence, | ||
| 1496 | nextEvent.data, | ||
| 1497 | ); | ||
| 1498 | session.pendingHistoryControlSequence = sanitized.pendingControlSequence; | ||
| 1499 | if (sanitized.visibleText.length > 0) { | ||
| 1500 | session.history = capHistory( | ||
| 1501 | `${session.history}${sanitized.visibleText}`, | ||
| 1502 | historyLineLimit, | ||
| 1503 | ); | ||
| 1504 | } | ||
| 1505 | const eventStamp = advanceEventSequence(session); | ||
| 1506 | |||
| 1507 | return { | ||
| 1508 | type: "output", | ||
| 1509 | threadId: session.threadId, | ||
| 1510 | terminalId: session.terminalId, | ||
| 1511 | sequence: eventStamp.sequence, | ||
| 1512 | history: sanitized.visibleText.length > 0 ? session.history : null, | ||
| 1513 | data: nextEvent.data, | ||
| 1514 | } as const; | ||
| 1515 | } | ||
| 1516 | |||
| 1517 | const process = session.process; | ||
| 1518 | cleanupProcessHandles(session); | ||
| 1519 | session.process = null; | ||
| 1520 | session.pid = null; | ||
| 1521 | session.hasRunningSubprocess = false; | ||
| 1522 | session.childCommandLabel = null; | ||
| 1523 | session.status = "exited"; | ||
| 1524 | session.pendingHistoryControlSequence = ""; | ||
| 1525 | session.pendingProcessEvents = []; | ||
| 1526 | session.pendingProcessEventIndex = 0; | ||
| 1527 | session.processEventDrainRunning = false; | ||
| 1528 | session.exitCode = Number.isInteger(nextEvent.event.exitCode) | ||
| 1529 | ? nextEvent.event.exitCode | ||
| 1530 | : null; | ||
| 1531 | session.exitSignal = Number.isInteger(nextEvent.event.signal) | ||
| 1532 | ? nextEvent.event.signal | ||
| 1533 | : null; | ||
| 1534 | const eventStamp = advanceEventSequence(session); | ||
| 1535 | |||
| 1536 | return { | ||
| 1537 | type: "exit", | ||
| 1538 | process, | ||
| 1539 | threadId: session.threadId, | ||
| 1540 | terminalId: session.terminalId, | ||
| 1541 | sequence: eventStamp.sequence, | ||
| 1542 | exitCode: session.exitCode, | ||
| 1543 | exitSignal: session.exitSignal, | ||
| 1544 | } as const; | ||
| 1545 | }); | ||
| 1546 | |||
| 1547 | if (action.type === "idle") { | ||
| 1548 | return; | ||
| 1549 | } | ||
| 1550 | |||
| 1551 | if (action.type === "output") { | ||
| 1552 | if (action.history !== null) { | ||
| 1553 | yield* queuePersist(action.threadId, action.terminalId, action.history); | ||
| 1554 | } | ||
| 1555 | |||
| 1556 | yield* publishEvent({ | ||
| 1557 | type: "output", | ||
| 1558 | threadId: action.threadId, | ||
| 1559 | terminalId: action.terminalId, | ||
| 1560 | sequence: action.sequence, | ||
| 1561 | data: action.data, | ||
| 1562 | }); | ||
| 1563 | continue; | ||
| 1564 | } | ||
| 1565 | |||
| 1566 | yield* clearKillFiber(action.process); | ||
| 1567 | yield* unregisterTerminal({ | ||
| 1568 | threadId: action.threadId, | ||
| 1569 | terminalId: action.terminalId, | ||
| 1570 | }); | ||
| 1571 | yield* publishEvent({ | ||
| 1572 | type: "exited", | ||
| 1573 | threadId: action.threadId, | ||
| 1574 | terminalId: action.terminalId, | ||
| 1575 | sequence: action.sequence, | ||
| 1576 | exitCode: action.exitCode, | ||
| 1577 | exitSignal: action.exitSignal, | ||
| 1578 | }); | ||
| 1579 | yield* evictInactiveSessionsIfNeeded(); | ||
| 1580 | return; | ||
| 1581 | } | ||
| 1582 | }); | ||
| 1583 | |||
| 1584 | const stopProcess = Effect.fn("terminal.stopProcess")(function* ( | ||
| 1585 | session: TerminalSessionState, | ||
| 1586 | ) { | ||
| 1587 | const process = session.process; | ||
| 1588 | if (!process) return; | ||
| 1589 | |||
| 1590 | const updatedAt = yield* nowIso; | ||
| 1591 | yield* modifyManagerState((state) => { | ||
| 1592 | cleanupProcessHandles(session); | ||
| 1593 | session.process = null; | ||
| 1594 | session.pid = null; | ||
| 1595 | session.hasRunningSubprocess = false; | ||
| 1596 | session.childCommandLabel = null; | ||
| 1597 | session.status = "exited"; | ||
| 1598 | session.pendingHistoryControlSequence = ""; | ||
| 1599 | session.pendingProcessEvents = []; | ||
| 1600 | session.pendingProcessEventIndex = 0; | ||
| 1601 | session.processEventDrainRunning = false; | ||
| 1602 | session.updatedAt = updatedAt; | ||
| 1603 | return [undefined, state] as const; | ||
| 1604 | }); | ||
| 1605 | |||
| 1606 | yield* clearKillFiber(process); | ||
| 1607 | yield* unregisterTerminal({ | ||
| 1608 | threadId: session.threadId, | ||
| 1609 | terminalId: session.terminalId, | ||
| 1610 | }); | ||
| 1611 | yield* startKillEscalation(process, session.threadId, session.terminalId); | ||
| 1612 | yield* evictInactiveSessionsIfNeeded(); | ||
| 1613 | }); | ||
| 1614 | |||
| 1615 | const trySpawn = Effect.fn("terminal.trySpawn")(function* ( | ||
| 1616 | shellCandidates: ReadonlyArray<ShellCandidate>, | ||
| 1617 | spawnEnv: NodeJS.ProcessEnv, | ||
| 1618 | session: TerminalSessionState, | ||
| 1619 | index = 0, | ||
| 1620 | lastError: PtySpawnError | null = null, | ||
| 1621 | ): Effect.fn.Return<{ process: PtyProcess; shellLabel: string }, PtySpawnError> { | ||
| 1622 | if (index >= shellCandidates.length) { | ||
| 1623 | const detail = lastError?.message ?? "Failed to spawn PTY process"; | ||
| 1624 | const tried = | ||
| 1625 | shellCandidates.length > 0 | ||
| 1626 | ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` | ||
| 1627 | : ""; | ||
| 1628 | return yield* new PtySpawnError({ | ||
| 1629 | adapter: "terminal-manager", | ||
| 1630 | message: `${detail}.${tried}`.trim(), | ||
| 1631 | ...(lastError ? { cause: lastError } : {}), | ||
| 1632 | }); | ||
| 1633 | } | ||
| 1634 | |||
| 1635 | const candidate = shellCandidates[index]; | ||
| 1636 | if (!candidate) { | ||
| 1637 | return yield* ( | ||
| 1638 | lastError ?? | ||
| 1639 | new PtySpawnError({ | ||
| 1640 | adapter: "terminal-manager", | ||
| 1641 | message: "No shell candidate available for PTY spawn.", | ||
| 1642 | }) | ||
| 1643 | ); | ||
| 1644 | } | ||
| 1645 | |||
| 1646 | const attempt = yield* Effect.result( | ||
| 1647 | options.ptyAdapter.spawn({ | ||
| 1648 | shell: candidate.shell, | ||
| 1649 | ...(candidate.args ? { args: candidate.args } : {}), | ||
| 1650 | cwd: session.cwd, | ||
| 1651 | cols: session.cols, | ||
| 1652 | rows: session.rows, | ||
| 1653 | env: spawnEnv, | ||
| 1654 | }), | ||
| 1655 | ); | ||
| 1656 | |||
| 1657 | if (attempt._tag === "Success") { | ||
| 1658 | return { | ||
| 1659 | process: attempt.success, | ||
| 1660 | shellLabel: formatShellCandidate(candidate), | ||
| 1661 | }; | ||
| 1662 | } | ||
| 1663 | |||
| 1664 | const spawnError = attempt.failure; | ||
| 1665 | if (!isRetryableShellSpawnError(spawnError)) { | ||
| 1666 | return yield* spawnError; | ||
| 1667 | } | ||
| 1668 | |||
| 1669 | return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); | ||
| 1670 | }); | ||
| 1671 | |||
| 1672 | const startSession = Effect.fn("terminal.startSession")(function* ( | ||
| 1673 | session: TerminalSessionState, | ||
| 1674 | input: TerminalStartInput, | ||
| 1675 | eventType: "started" | "restarted", | ||
| 1676 | ) { | ||
| 1677 | yield* stopProcess(session); | ||
| 1678 | yield* Effect.annotateCurrentSpan({ | ||
| 1679 | "terminal.thread_id": session.threadId, | ||
| 1680 | "terminal.id": session.terminalId, | ||
| 1681 | "terminal.event_type": eventType, | ||
| 1682 | "terminal.cwd": input.cwd, | ||
| 1683 | }); | ||
| 1684 | |||
| 1685 | const startingAt = yield* nowIso; | ||
| 1686 | yield* modifyManagerState((state) => { | ||
| 1687 | session.status = "starting"; | ||
| 1688 | session.cwd = input.cwd; | ||
| 1689 | session.worktreePath = input.worktreePath ?? null; | ||
| 1690 | session.cols = input.cols; | ||
| 1691 | session.rows = input.rows; | ||
| 1692 | session.exitCode = null; | ||
| 1693 | session.exitSignal = null; | ||
| 1694 | session.hasRunningSubprocess = false; | ||
| 1695 | session.childCommandLabel = null; | ||
| 1696 | session.pendingProcessEvents = []; | ||
| 1697 | session.pendingProcessEventIndex = 0; | ||
| 1698 | session.processEventDrainRunning = false; | ||
| 1699 | session.updatedAt = startingAt; | ||
| 1700 | return [undefined, state] as const; | ||
| 1701 | }); | ||
| 1702 | |||
| 1703 | let ptyProcess: PtyProcess | null = null; | ||
| 1704 | let startedShell: string | null = null; | ||
| 1705 | |||
| 1706 | const startResult = yield* Effect.result( | ||
| 1707 | increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( | ||
| 1708 | Effect.andThen( | ||
| 1709 | Effect.gen(function* () { | ||
| 1710 | const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); | ||
| 1711 | const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); | ||
| 1712 | const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); | ||
| 1713 | ptyProcess = spawnResult.process; | ||
| 1714 | startedShell = spawnResult.shellLabel; | ||
| 1715 | |||
| 1716 | const processPid = ptyProcess.pid; | ||
| 1717 | const unsubscribeData = ptyProcess.onData((data) => { | ||
| 1718 | if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { | ||
| 1719 | return; | ||
| 1720 | } | ||
| 1721 | runFork(drainProcessEvents(session, processPid)); | ||
| 1722 | }); | ||
| 1723 | const unsubscribeExit = ptyProcess.onExit((event) => { | ||
| 1724 | if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { | ||
| 1725 | return; | ||
| 1726 | } | ||
| 1727 | runFork(drainProcessEvents(session, processPid)); | ||
| 1728 | }); | ||
| 1729 | |||
| 1730 | let eventStamp: ReturnType<typeof advanceEventSequence> = { | ||
| 1731 | updatedAt: session.updatedAt, | ||
| 1732 | sequence: session.eventSequence, | ||
| 1733 | }; | ||
| 1734 | yield* modifyManagerState((state) => { | ||
| 1735 | session.process = ptyProcess; | ||
| 1736 | session.pid = processPid; | ||
| 1737 | session.status = "running"; | ||
| 1738 | session.unsubscribeData = unsubscribeData; | ||
| 1739 | session.unsubscribeExit = unsubscribeExit; | ||
| 1740 | eventStamp = advanceEventSequence(session); | ||
| 1741 | return [undefined, state] as const; | ||
| 1742 | }); | ||
| 1743 | |||
| 1744 | yield* publishEvent({ | ||
| 1745 | type: eventType, | ||
| 1746 | threadId: session.threadId, | ||
| 1747 | terminalId: session.terminalId, | ||
| 1748 | sequence: eventStamp.sequence, | ||
| 1749 | snapshot: snapshot(session), | ||
| 1750 | }); | ||
| 1751 | }), | ||
| 1752 | ), | ||
| 1753 | ), | ||
| 1754 | ); | ||
| 1755 | |||
| 1756 | if (startResult._tag === "Success") { | ||
| 1757 | return; | ||
| 1758 | } | ||
| 1759 | |||
| 1760 | { | ||
| 1761 | const error = startResult.failure; | ||
| 1762 | if (ptyProcess) { | ||
| 1763 | yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); | ||
| 1764 | } | ||
| 1765 | |||
| 1766 | yield* modifyManagerState((state) => { | ||
| 1767 | session.status = "error"; | ||
| 1768 | session.pid = null; | ||
| 1769 | session.process = null; | ||
| 1770 | session.unsubscribeData = null; | ||
| 1771 | session.unsubscribeExit = null; | ||
| 1772 | session.hasRunningSubprocess = false; | ||
| 1773 | session.childCommandLabel = null; | ||
| 1774 | session.pendingProcessEvents = []; | ||
| 1775 | session.pendingProcessEventIndex = 0; | ||
| 1776 | session.processEventDrainRunning = false; | ||
| 1777 | advanceEventSequence(session); | ||
| 1778 | return [undefined, state] as const; | ||
| 1779 | }); | ||
| 1780 | yield* unregisterTerminal({ | ||
| 1781 | threadId: session.threadId, | ||
| 1782 | terminalId: session.terminalId, | ||
| 1783 | }); | ||
| 1784 | |||
| 1785 | yield* evictInactiveSessionsIfNeeded(); | ||
| 1786 | |||
| 1787 | const message = error.message; | ||
| 1788 | yield* publishEvent({ | ||
| 1789 | type: "error", | ||
| 1790 | threadId: session.threadId, | ||
| 1791 | terminalId: session.terminalId, | ||
| 1792 | sequence: session.eventSequence, | ||
| 1793 | message, | ||
| 1794 | }); | ||
| 1795 | yield* Effect.logError("failed to start terminal", { | ||
| 1796 | threadId: session.threadId, | ||
| 1797 | terminalId: session.terminalId, | ||
| 1798 | error: message, | ||
| 1799 | ...(startedShell ? { shell: startedShell } : {}), | ||
| 1800 | }); | ||
| 1801 | } | ||
| 1802 | }); | ||
| 1803 | |||
| 1804 | const closeSession = Effect.fn("terminal.closeSession")(function* ( | ||
| 1805 | threadId: string, | ||
| 1806 | terminalId: string, | ||
| 1807 | deleteHistoryOnClose: boolean, | ||
| 1808 | ) { | ||
| 1809 | const key = toSessionKey(threadId, terminalId); | ||
| 1810 | const session = yield* getSession(threadId, terminalId); | ||
| 1811 | const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; | ||
| 1812 | |||
| 1813 | if (Option.isSome(session)) { | ||
| 1814 | yield* stopProcess(session.value); | ||
| 1815 | yield* unregisterTerminal({ threadId, terminalId }); | ||
| 1816 | yield* persistHistory(threadId, terminalId, session.value.history); | ||
| 1817 | } | ||
| 1818 | |||
| 1819 | yield* flushPersist(threadId, terminalId); | ||
| 1820 | |||
| 1821 | const removed = yield* modifyManagerState((state) => { | ||
| 1822 | if (!state.sessions.has(key)) { | ||
| 1823 | return [false, state] as const; | ||
| 1824 | } | ||
| 1825 | const sessions = new Map(state.sessions); | ||
| 1826 | sessions.delete(key); | ||
| 1827 | return [true, { ...state, sessions }] as const; | ||
| 1828 | }); | ||
| 1829 | |||
| 1830 | if (removed) { | ||
| 1831 | yield* publishEvent({ | ||
| 1832 | type: "closed", | ||
| 1833 | threadId, | ||
| 1834 | terminalId, | ||
| 1835 | sequence: closedEventSequence, | ||
| 1836 | }); | ||
| 1837 | } | ||
| 1838 | |||
| 1839 | if (deleteHistoryOnClose) { | ||
| 1840 | yield* deleteHistory(threadId, terminalId); | ||
| 1841 | } | ||
| 1842 | }); | ||
| 1843 | |||
| 1844 | const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { | ||
| 1845 | const state = yield* readManagerState; | ||
| 1846 | const runningSessions = [...state.sessions.values()].filter( | ||
| 1847 | (session): session is TerminalSessionState & { pid: number } => | ||
| 1848 | session.status === "running" && Number.isInteger(session.pid), | ||
| 1849 | ); | ||
| 1850 | |||
| 1851 | if (runningSessions.length === 0) { | ||
| 1852 | return; | ||
| 1853 | } | ||
| 1854 | |||
| 1855 | const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( | ||
| 1856 | session: TerminalSessionState & { pid: number }, | ||
| 1857 | ) { | ||
| 1858 | const terminalPid = session.pid; | ||
| 1859 | const inspectResult = yield* subprocessInspector(terminalPid).pipe( | ||
| 1860 | Effect.map(Option.some), | ||
| 1861 | Effect.catch((reason) => | ||
| 1862 | Effect.logWarning("failed to check terminal subprocess activity", { | ||
| 1863 | threadId: session.threadId, | ||
| 1864 | terminalId: session.terminalId, | ||
| 1865 | terminalPid, | ||
| 1866 | reason, | ||
| 1867 | }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())), | ||
| 1868 | ), | ||
| 1869 | ); | ||
| 1870 | |||
| 1871 | if (Option.isNone(inspectResult)) { | ||
| 1872 | return; | ||
| 1873 | } | ||
| 1874 | |||
| 1875 | const next = inspectResult.value; | ||
| 1876 | yield* registerTerminalProcesses({ | ||
| 1877 | threadId: session.threadId, | ||
| 1878 | terminalId: session.terminalId, | ||
| 1879 | processIds: next.processIds, | ||
| 1880 | }); | ||
| 1881 | const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; | ||
| 1882 | const event = yield* modifyManagerState((state) => { | ||
| 1883 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( | ||
| 1884 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), | ||
| 1885 | ); | ||
| 1886 | if ( | ||
| 1887 | Option.isNone(liveSession) || | ||
| 1888 | liveSession.value.status !== "running" || | ||
| 1889 | liveSession.value.pid !== terminalPid || | ||
| 1890 | (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && | ||
| 1891 | liveSession.value.childCommandLabel === nextChildLabel) | ||
| 1892 | ) { | ||
| 1893 | return [Option.none(), state] as const; | ||
| 1894 | } | ||
| 1895 | |||
| 1896 | liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; | ||
| 1897 | liveSession.value.childCommandLabel = nextChildLabel; | ||
| 1898 | const eventStamp = advanceEventSequence(liveSession.value); | ||
| 1899 | |||
| 1900 | return [ | ||
| 1901 | Option.some({ | ||
| 1902 | type: "activity" as const, | ||
| 1903 | threadId: liveSession.value.threadId, | ||
| 1904 | terminalId: liveSession.value.terminalId, | ||
| 1905 | sequence: eventStamp.sequence, | ||
| 1906 | hasRunningSubprocess: next.hasRunningSubprocess, | ||
| 1907 | label: terminalWireLabel(liveSession.value), | ||
| 1908 | }), | ||
| 1909 | state, | ||
| 1910 | ] as const; | ||
| 1911 | }); | ||
| 1912 | |||
| 1913 | if (Option.isSome(event)) { | ||
| 1914 | yield* publishEvent(event.value); | ||
| 1915 | } | ||
| 1916 | }); | ||
| 1917 | |||
| 1918 | yield* Effect.forEach(runningSessions, checkSubprocessActivity, { | ||
| 1919 | concurrency: "unbounded", | ||
| 1920 | discard: true, | ||
| 1921 | }); | ||
| 1922 | }); | ||
| 1923 | |||
| 1924 | const hasRunningSessions = readManagerState.pipe( | ||
| 1925 | Effect.map((state) => | ||
| 1926 | [...state.sessions.values()].some((session) => session.status === "running"), | ||
| 1927 | ), | ||
| 1928 | ); | ||
| 1929 | |||
| 1930 | yield* Effect.forever( | ||
| 1931 | hasRunningSessions.pipe( | ||
| 1932 | Effect.flatMap((active) => | ||
| 1933 | active | ||
| 1934 | ? pollSubprocessActivity().pipe( | ||
| 1935 | Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), | ||
| 1936 | ) | ||
| 1937 | : Effect.sleep(subprocessPollIntervalMs), | ||
| 1938 | ), | ||
| 1939 | ), | ||
| 1940 | ).pipe(Effect.forkIn(workerScope)); | ||
| 1941 | |||
| 1942 | yield* Effect.addFinalizer(() => | ||
| 1943 | Effect.gen(function* () { | ||
| 1944 | const sessions = yield* modifyManagerState( | ||
| 1945 | (state) => | ||
| 1946 | [ | ||
| 1947 | [...state.sessions.values()], | ||
| 1948 | { | ||
| 1949 | ...state, | ||
| 1950 | sessions: new Map(), | ||
| 1951 | }, | ||
| 1952 | ] as const, | ||
| 1953 | ); | ||
| 1954 | |||
| 1955 | const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( | ||
| 1956 | session: TerminalSessionState, | ||
| 1957 | ) { | ||
| 1958 | cleanupProcessHandles(session); | ||
| 1959 | if (!session.process) return; | ||
| 1960 | yield* clearKillFiber(session.process); | ||
| 1961 | yield* runKillEscalation(session.process, session.threadId, session.terminalId); | ||
| 1962 | }); | ||
| 1963 | |||
| 1964 | yield* Effect.forEach(sessions, cleanupSession, { | ||
| 1965 | concurrency: "unbounded", | ||
| 1966 | discard: true, | ||
| 1967 | }); | ||
| 1968 | }).pipe(Effect.ignoreCause({ log: true })), | ||
| 1969 | ); | ||
| 1970 | |||
| 1971 | const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { | ||
| 1972 | const terminalId = input.terminalId; | ||
| 1973 | yield* assertValidCwd(input.cwd); | ||
| 1974 | |||
| 1975 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 1976 | const existing = yield* getSession(input.threadId, terminalId); | ||
| 1977 | if (Option.isNone(existing)) { | ||
| 1978 | yield* flushPersist(input.threadId, terminalId); | ||
| 1979 | const history = yield* readHistory(input.threadId, terminalId); | ||
| 1980 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | ||
| 1981 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | ||
| 1982 | const session: TerminalSessionState = { | ||
| 1983 | threadId: input.threadId, | ||
| 1984 | terminalId, | ||
| 1985 | cwd: input.cwd, | ||
| 1986 | worktreePath: input.worktreePath ?? null, | ||
| 1987 | status: "starting", | ||
| 1988 | pid: null, | ||
| 1989 | history, | ||
| 1990 | pendingHistoryControlSequence: "", | ||
| 1991 | pendingProcessEvents: [], | ||
| 1992 | pendingProcessEventIndex: 0, | ||
| 1993 | processEventDrainRunning: false, | ||
| 1994 | exitCode: null, | ||
| 1995 | exitSignal: null, | ||
| 1996 | updatedAt: yield* nowIso, | ||
| 1997 | eventSequence: 0, | ||
| 1998 | cols, | ||
| 1999 | rows, | ||
| 2000 | process: null, | ||
| 2001 | unsubscribeData: null, | ||
| 2002 | unsubscribeExit: null, | ||
| 2003 | hasRunningSubprocess: false, | ||
| 2004 | childCommandLabel: null, | ||
| 2005 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 2006 | }; | ||
| 2007 | |||
| 2008 | const createdSession = session; | ||
| 2009 | yield* modifyManagerState((state) => { | ||
| 2010 | const sessions = new Map(state.sessions); | ||
| 2011 | sessions.set(sessionKey, createdSession); | ||
| 2012 | return [undefined, { ...state, sessions }] as const; | ||
| 2013 | }); | ||
| 2014 | |||
| 2015 | yield* evictInactiveSessionsIfNeeded(); | ||
| 2016 | yield* startSession( | ||
| 2017 | session, | ||
| 2018 | { | ||
| 2019 | threadId: input.threadId, | ||
| 2020 | terminalId, | ||
| 2021 | cwd: input.cwd, | ||
| 2022 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 2023 | cols, | ||
| 2024 | rows, | ||
| 2025 | ...(input.env ? { env: input.env } : {}), | ||
| 2026 | }, | ||
| 2027 | "started", | ||
| 2028 | ); | ||
| 2029 | return snapshot(session); | ||
| 2030 | } | ||
| 2031 | |||
| 2032 | const liveSession = existing.value; | ||
| 2033 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | ||
| 2034 | const currentRuntimeEnv = liveSession.runtimeEnv; | ||
| 2035 | const targetCols = input.cols ?? liveSession.cols; | ||
| 2036 | const targetRows = input.rows ?? liveSession.rows; | ||
| 2037 | const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); | ||
| 2038 | const nextWorktreePath = | ||
| 2039 | input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; | ||
| 2040 | const launchContextChanged = | ||
| 2041 | liveSession.cwd !== input.cwd || | ||
| 2042 | runtimeEnvChanged || | ||
| 2043 | liveSession.worktreePath !== nextWorktreePath; | ||
| 2044 | |||
| 2045 | if (launchContextChanged) { | ||
| 2046 | yield* stopProcess(liveSession); | ||
| 2047 | liveSession.cwd = input.cwd; | ||
| 2048 | liveSession.worktreePath = nextWorktreePath; | ||
| 2049 | liveSession.runtimeEnv = nextRuntimeEnv; | ||
| 2050 | liveSession.history = ""; | ||
| 2051 | liveSession.pendingHistoryControlSequence = ""; | ||
| 2052 | liveSession.pendingProcessEvents = []; | ||
| 2053 | liveSession.pendingProcessEventIndex = 0; | ||
| 2054 | liveSession.processEventDrainRunning = false; | ||
| 2055 | yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); | ||
| 2056 | } else if (liveSession.status === "exited" || liveSession.status === "error") { | ||
| 2057 | liveSession.runtimeEnv = nextRuntimeEnv; | ||
| 2058 | liveSession.worktreePath = nextWorktreePath; | ||
| 2059 | liveSession.history = ""; | ||
| 2060 | liveSession.pendingHistoryControlSequence = ""; | ||
| 2061 | liveSession.pendingProcessEvents = []; | ||
| 2062 | liveSession.pendingProcessEventIndex = 0; | ||
| 2063 | liveSession.processEventDrainRunning = false; | ||
| 2064 | yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); | ||
| 2065 | } | ||
| 2066 | |||
| 2067 | if (!liveSession.process) { | ||
| 2068 | yield* startSession( | ||
| 2069 | liveSession, | ||
| 2070 | { | ||
| 2071 | threadId: input.threadId, | ||
| 2072 | terminalId, | ||
| 2073 | cwd: input.cwd, | ||
| 2074 | worktreePath: liveSession.worktreePath, | ||
| 2075 | cols: targetCols, | ||
| 2076 | rows: targetRows, | ||
| 2077 | ...(input.env ? { env: input.env } : {}), | ||
| 2078 | }, | ||
| 2079 | "started", | ||
| 2080 | ); | ||
| 2081 | return snapshot(liveSession); | ||
| 2082 | } | ||
| 2083 | |||
| 2084 | if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { | ||
| 2085 | liveSession.cols = targetCols; | ||
| 2086 | liveSession.rows = targetRows; | ||
| 2087 | liveSession.updatedAt = yield* nowIso; | ||
| 2088 | liveSession.process.resize(targetCols, targetRows); | ||
| 2089 | } | ||
| 2090 | |||
| 2091 | return snapshot(liveSession); | ||
| 2092 | }); | ||
| 2093 | |||
| 2094 | const open: TerminalManagerShape["open"] = (input) => | ||
| 2095 | withThreadLock(input.threadId, openLocked(input)); | ||
| 2096 | |||
| 2097 | const openOrAttachForStream = (input: TerminalAttachInput) => | ||
| 2098 | withThreadLock( | ||
| 2099 | input.threadId, | ||
| 2100 | Effect.gen(function* () { | ||
| 2101 | const terminalId = input.terminalId; | ||
| 2102 | const existing = yield* getSession(input.threadId, terminalId); | ||
| 2103 | |||
| 2104 | if (Option.isNone(existing)) { | ||
| 2105 | if (!input.cwd) { | ||
| 2106 | return yield* new TerminalSessionLookupError({ | ||
| 2107 | threadId: input.threadId, | ||
| 2108 | terminalId, | ||
| 2109 | }); | ||
| 2110 | } | ||
| 2111 | |||
| 2112 | return yield* openLocked({ | ||
| 2113 | ...input, | ||
| 2114 | terminalId, | ||
| 2115 | cwd: input.cwd, | ||
| 2116 | }); | ||
| 2117 | } | ||
| 2118 | |||
| 2119 | const session = existing.value; | ||
| 2120 | const targetCols = input.cols ?? session.cols; | ||
| 2121 | const targetRows = input.rows ?? session.rows; | ||
| 2122 | |||
| 2123 | if (!session.process && input.cwd && input.restartIfNotRunning === true) { | ||
| 2124 | return yield* openLocked({ | ||
| 2125 | ...input, | ||
| 2126 | terminalId, | ||
| 2127 | cwd: input.cwd, | ||
| 2128 | }); | ||
| 2129 | } | ||
| 2130 | |||
| 2131 | if ( | ||
| 2132 | session.process && | ||
| 2133 | session.status === "running" && | ||
| 2134 | (session.cols !== targetCols || session.rows !== targetRows) | ||
| 2135 | ) { | ||
| 2136 | session.cols = targetCols; | ||
| 2137 | session.rows = targetRows; | ||
| 2138 | session.updatedAt = yield* nowIso; | ||
| 2139 | yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); | ||
| 2140 | } | ||
| 2141 | |||
| 2142 | return snapshot(session); | ||
| 2143 | }), | ||
| 2144 | ); | ||
| 2145 | |||
| 2146 | const readAllTerminalMetadata = () => | ||
| 2147 | readManagerState.pipe( | ||
| 2148 | Effect.map((state) => | ||
| 2149 | [...state.sessions.values()] | ||
| 2150 | .map(summary) | ||
| 2151 | .sort( | ||
| 2152 | (left, right) => | ||
| 2153 | right.updatedAt.localeCompare(left.updatedAt) || | ||
| 2154 | left.threadId.localeCompare(right.threadId) || | ||
| 2155 | left.terminalId.localeCompare(right.terminalId), | ||
| 2156 | ), | ||
| 2157 | ), | ||
| 2158 | ); | ||
| 2159 | |||
| 2160 | const readTerminalMetadata = (input: { | ||
| 2161 | readonly threadId: string; | ||
| 2162 | readonly terminalId: string; | ||
| 2163 | }) => | ||
| 2164 | getSession(input.threadId, input.terminalId).pipe( | ||
| 2165 | Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), | ||
| 2166 | ); | ||
| 2167 | |||
| 2168 | const subscribe: TerminalManagerShape["subscribe"] = (listener) => | ||
| 2169 | Effect.sync(() => { | ||
| 2170 | terminalEventListeners.add(listener); | ||
| 2171 | return () => { | ||
| 2172 | terminalEventListeners.delete(listener); | ||
| 2173 | }; | ||
| 2174 | }); | ||
| 2175 | |||
| 2176 | const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => { | ||
| 2177 | let unsubscribe: (() => void) | null = null; | ||
| 2178 | |||
| 2179 | return Effect.gen(function* () { | ||
| 2180 | const bufferedEvents: TerminalEvent[] = []; | ||
| 2181 | let deliverLive = false; | ||
| 2182 | |||
| 2183 | unsubscribe = yield* subscribe((event) => { | ||
| 2184 | if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { | ||
| 2185 | return Effect.void; | ||
| 2186 | } | ||
| 2187 | |||
| 2188 | if (!deliverLive) { | ||
| 2189 | bufferedEvents.push(event); | ||
| 2190 | return Effect.void; | ||
| 2191 | } | ||
| 2192 | |||
| 2193 | const attachEvent = terminalEventToAttachEvent(event); | ||
| 2194 | return attachEvent ? listener(attachEvent) : Effect.void; | ||
| 2195 | }); | ||
| 2196 | |||
| 2197 | const initialSnapshot = yield* openOrAttachForStream(input); | ||
| 2198 | |||
| 2199 | yield* listener({ | ||
| 2200 | type: "snapshot", | ||
| 2201 | snapshot: initialSnapshot, | ||
| 2202 | }); | ||
| 2203 | |||
| 2204 | for (const event of bufferedEvents) { | ||
| 2205 | if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { | ||
| 2206 | continue; | ||
| 2207 | } | ||
| 2208 | |||
| 2209 | const attachEvent = terminalEventToAttachEvent(event); | ||
| 2210 | if (attachEvent) { | ||
| 2211 | yield* listener(attachEvent); | ||
| 2212 | } | ||
| 2213 | } | ||
| 2214 | |||
| 2215 | deliverLive = true; | ||
| 2216 | return () => { | ||
| 2217 | unsubscribe?.(); | ||
| 2218 | unsubscribe = null; | ||
| 2219 | }; | ||
| 2220 | }).pipe( | ||
| 2221 | Effect.catchCause((cause) => | ||
| 2222 | Effect.flatMap( | ||
| 2223 | Effect.sync(() => { | ||
| 2224 | unsubscribe?.(); | ||
| 2225 | unsubscribe = null; | ||
| 2226 | }), | ||
| 2227 | () => Effect.failCause(cause), | ||
| 2228 | ), | ||
| 2229 | ), | ||
| 2230 | ); | ||
| 2231 | }; | ||
| 2232 | |||
| 2233 | const metadataEventFromTerminalEvent = ( | ||
| 2234 | event: TerminalEvent, | ||
| 2235 | ): Effect.Effect<TerminalMetadataStreamEvent | null> => { | ||
| 2236 | if (!shouldPublishTerminalMetadataEvent(event)) { | ||
| 2237 | return Effect.succeed(null); | ||
| 2238 | } | ||
| 2239 | |||
| 2240 | if (event.type === "closed") { | ||
| 2241 | return Effect.succeed({ | ||
| 2242 | type: "remove" as const, | ||
| 2243 | threadId: event.threadId, | ||
| 2244 | terminalId: event.terminalId, | ||
| 2245 | }); | ||
| 2246 | } | ||
| 2247 | |||
| 2248 | return readTerminalMetadata({ | ||
| 2249 | threadId: event.threadId, | ||
| 2250 | terminalId: event.terminalId, | ||
| 2251 | }).pipe( | ||
| 2252 | Effect.map((terminal) => | ||
| 2253 | terminal | ||
| 2254 | ? { | ||
| 2255 | type: "upsert" as const, | ||
| 2256 | terminal, | ||
| 2257 | } | ||
| 2258 | : null, | ||
| 2259 | ), | ||
| 2260 | ); | ||
| 2261 | }; | ||
| 2262 | |||
| 2263 | const offerMetadataEvent = ( | ||
| 2264 | listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>, | ||
| 2265 | event: TerminalEvent, | ||
| 2266 | ) => | ||
| 2267 | metadataEventFromTerminalEvent(event).pipe( | ||
| 2268 | Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), | ||
| 2269 | ); | ||
| 2270 | |||
| 2271 | const subscribeMetadata: TerminalManagerShape["subscribeMetadata"] = (listener) => { | ||
| 2272 | let unsubscribe: (() => void) | null = null; | ||
| 2273 | |||
| 2274 | return Effect.gen(function* () { | ||
| 2275 | const bufferedEvents: TerminalEvent[] = []; | ||
| 2276 | let deliverLive = false; | ||
| 2277 | |||
| 2278 | unsubscribe = yield* subscribe((event) => { | ||
| 2279 | if (!deliverLive) { | ||
| 2280 | bufferedEvents.push(event); | ||
| 2281 | return Effect.void; | ||
| 2282 | } | ||
| 2283 | |||
| 2284 | return offerMetadataEvent(listener, event); | ||
| 2285 | }); | ||
| 2286 | |||
| 2287 | const terminals = yield* readAllTerminalMetadata(); | ||
| 2288 | yield* listener({ | ||
| 2289 | type: "snapshot", | ||
| 2290 | terminals, | ||
| 2291 | }); | ||
| 2292 | |||
| 2293 | for (const event of bufferedEvents) { | ||
| 2294 | yield* offerMetadataEvent(listener, event); | ||
| 2295 | } | ||
| 2296 | |||
| 2297 | deliverLive = true; | ||
| 2298 | return () => { | ||
| 2299 | unsubscribe?.(); | ||
| 2300 | unsubscribe = null; | ||
| 2301 | }; | ||
| 2302 | }).pipe( | ||
| 2303 | Effect.catchCause((cause) => | ||
| 2304 | Effect.flatMap( | ||
| 2305 | Effect.sync(() => { | ||
| 2306 | unsubscribe?.(); | ||
| 2307 | unsubscribe = null; | ||
| 2308 | }), | ||
| 2309 | () => Effect.failCause(cause), | ||
| 2310 | ), | ||
| 2311 | ), | ||
| 2312 | ); | ||
| 2313 | }; | ||
| 2314 | |||
| 2315 | const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { | ||
| 2316 | const terminalId = input.terminalId; | ||
| 2317 | const session = yield* requireSession(input.threadId, terminalId); | ||
| 2318 | const process = session.process; | ||
| 2319 | if (!process || session.status !== "running") { | ||
| 2320 | if (session.status === "exited") return; | ||
| 2321 | return yield* new TerminalNotRunningError({ | ||
| 2322 | threadId: input.threadId, | ||
| 2323 | terminalId, | ||
| 2324 | }); | ||
| 2325 | } | ||
| 2326 | yield* Effect.sync(() => process.write(input.data)); | ||
| 2327 | }); | ||
| 2328 | |||
| 2329 | const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { | ||
| 2330 | const session = yield* getSession(input.threadId, input.terminalId); | ||
| 2331 | // ResizeObserver traffic can already be in flight when the UI closes the session. | ||
| 2332 | if (Option.isNone(session)) { | ||
| 2333 | return; | ||
| 2334 | } | ||
| 2335 | const process = session.value.process; | ||
| 2336 | if (!process || session.value.status !== "running") { | ||
| 2337 | return; | ||
| 2338 | } | ||
| 2339 | session.value.cols = input.cols; | ||
| 2340 | session.value.rows = input.rows; | ||
| 2341 | session.value.updatedAt = yield* nowIso; | ||
| 2342 | yield* Effect.sync(() => process.resize(input.cols, input.rows)); | ||
| 2343 | }); | ||
| 2344 | |||
| 2345 | const resize: TerminalManagerShape["resize"] = (input) => | ||
| 2346 | withThreadLock(input.threadId, resizeLocked(input)); | ||
| 2347 | |||
| 2348 | const clear: TerminalManagerShape["clear"] = (input) => | ||
| 2349 | withThreadLock( | ||
| 2350 | input.threadId, | ||
| 2351 | Effect.gen(function* () { | ||
| 2352 | const terminalId = input.terminalId; | ||
| 2353 | const session = yield* requireSession(input.threadId, terminalId); | ||
| 2354 | session.history = ""; | ||
| 2355 | session.pendingHistoryControlSequence = ""; | ||
| 2356 | session.pendingProcessEvents = []; | ||
| 2357 | session.pendingProcessEventIndex = 0; | ||
| 2358 | session.processEventDrainRunning = false; | ||
| 2359 | const eventStamp = advanceEventSequence(session); | ||
| 2360 | yield* persistHistory(input.threadId, terminalId, session.history); | ||
| 2361 | yield* publishEvent({ | ||
| 2362 | type: "cleared", | ||
| 2363 | threadId: input.threadId, | ||
| 2364 | terminalId, | ||
| 2365 | sequence: eventStamp.sequence, | ||
| 2366 | }); | ||
| 2367 | }), | ||
| 2368 | ); | ||
| 2369 | |||
| 2370 | const restart: TerminalManagerShape["restart"] = (input) => | ||
| 2371 | withThreadLock( | ||
| 2372 | input.threadId, | ||
| 2373 | Effect.gen(function* () { | ||
| 2374 | yield* increment(terminalRestartsTotal, { scope: "thread" }); | ||
| 2375 | const terminalId = input.terminalId; | ||
| 2376 | yield* assertValidCwd(input.cwd); | ||
| 2377 | |||
| 2378 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 2379 | const existingSession = yield* getSession(input.threadId, terminalId); | ||
| 2380 | let session: TerminalSessionState; | ||
| 2381 | if (Option.isNone(existingSession)) { | ||
| 2382 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | ||
| 2383 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | ||
| 2384 | session = { | ||
| 2385 | threadId: input.threadId, | ||
| 2386 | terminalId, | ||
| 2387 | cwd: input.cwd, | ||
| 2388 | worktreePath: input.worktreePath ?? null, | ||
| 2389 | status: "starting", | ||
| 2390 | pid: null, | ||
| 2391 | history: "", | ||
| 2392 | pendingHistoryControlSequence: "", | ||
| 2393 | pendingProcessEvents: [], | ||
| 2394 | pendingProcessEventIndex: 0, | ||
| 2395 | processEventDrainRunning: false, | ||
| 2396 | exitCode: null, | ||
| 2397 | exitSignal: null, | ||
| 2398 | updatedAt: yield* nowIso, | ||
| 2399 | eventSequence: 0, | ||
| 2400 | cols, | ||
| 2401 | rows, | ||
| 2402 | process: null, | ||
| 2403 | unsubscribeData: null, | ||
| 2404 | unsubscribeExit: null, | ||
| 2405 | hasRunningSubprocess: false, | ||
| 2406 | childCommandLabel: null, | ||
| 2407 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 2408 | }; | ||
| 2409 | const createdSession = session; | ||
| 2410 | yield* modifyManagerState((state) => { | ||
| 2411 | const sessions = new Map(state.sessions); | ||
| 2412 | sessions.set(sessionKey, createdSession); | ||
| 2413 | return [undefined, { ...state, sessions }] as const; | ||
| 2414 | }); | ||
| 2415 | yield* evictInactiveSessionsIfNeeded(); | ||
| 2416 | } else { | ||
| 2417 | session = existingSession.value; | ||
| 2418 | yield* stopProcess(session); | ||
| 2419 | session.cwd = input.cwd; | ||
| 2420 | session.worktreePath = input.worktreePath ?? null; | ||
| 2421 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | ||
| 2422 | } | ||
| 2423 | |||
| 2424 | const cols = input.cols ?? session.cols; | ||
| 2425 | const rows = input.rows ?? session.rows; | ||
| 2426 | |||
| 2427 | session.history = ""; | ||
| 2428 | session.pendingHistoryControlSequence = ""; | ||
| 2429 | session.pendingProcessEvents = []; | ||
| 2430 | session.pendingProcessEventIndex = 0; | ||
| 2431 | session.processEventDrainRunning = false; | ||
| 2432 | yield* persistHistory(input.threadId, terminalId, session.history); | ||
| 2433 | yield* startSession( | ||
| 2434 | session, | ||
| 2435 | { | ||
| 2436 | threadId: input.threadId, | ||
| 2437 | terminalId, | ||
| 2438 | cwd: input.cwd, | ||
| 2439 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 2440 | cols, | ||
| 2441 | rows, | ||
| 2442 | ...(input.env ? { env: input.env } : {}), | ||
| 2443 | }, | ||
| 2444 | "restarted", | ||
| 2445 | ); | ||
| 2446 | return snapshot(session); | ||
| 2447 | }), | ||
| 2448 | ); | ||
| 2449 | |||
| 2450 | const close: TerminalManagerShape["close"] = (input) => | ||
| 2451 | withThreadLock( | ||
| 2452 | input.threadId, | ||
| 2453 | Effect.gen(function* () { | ||
| 2454 | if (input.terminalId) { | ||
| 2455 | yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); | ||
| 2456 | return; | ||
| 2457 | } | ||
| 2458 | |||
| 2459 | const threadSessions = yield* sessionsForThread(input.threadId); | ||
| 2460 | yield* Effect.forEach( | ||
| 2461 | threadSessions, | ||
| 2462 | (session) => closeSession(input.threadId, session.terminalId, false), | ||
| 2463 | { discard: true }, | ||
| 2464 | ); | ||
| 2465 | |||
| 2466 | if (input.deleteHistory) { | ||
| 2467 | yield* deleteAllHistoryForThread(input.threadId); | ||
| 2468 | } | ||
| 2469 | }), | ||
| 2470 | ); | ||
| 2471 | |||
| 2472 | return { | ||
| 2473 | open, | ||
| 2474 | attachStream, | ||
| 2475 | write, | ||
| 2476 | resize, | ||
| 2477 | clear, | ||
| 2478 | restart, | ||
| 2479 | close, | ||
| 2480 | subscribe, | ||
| 2481 | subscribeMetadata, | ||
| 2482 | } satisfies TerminalManagerShape; | ||
| 2483 | }, | ||
| 2484 | ); | ||
| 2485 | |||
| 2486 | export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe( | ||
| 2487 | Layer.provide(ProcessRunner.layer), | ||
| 2488 | ); | ||
apps/server/src/terminal/Layers/NodePTY.test.ts deleted-58| ... | @@ -1,58 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import { vi } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { PtyAdapter } from "../Services/PTY.ts"; | ||
| 9 | import { layer } from "./NodePTY.ts"; | ||
| 10 | |||
| 11 | const spawn = vi.fn(() => ({ | ||
| 12 | pid: 42, | ||
| 13 | write: vi.fn(), | ||
| 14 | resize: vi.fn(), | ||
| 15 | kill: vi.fn(), | ||
| 16 | onData: vi.fn(() => ({ dispose: vi.fn() })), | ||
| 17 | onExit: vi.fn(() => ({ dispose: vi.fn() })), | ||
| 18 | })); | ||
| 19 | |||
| 20 | vi.mock("node-pty", () => ({ spawn })); | ||
| 21 | |||
| 22 | const testLayer = layer.pipe( | ||
| 23 | Layer.provide( | ||
| 24 | Layer.mergeAll( | ||
| 25 | NodeServices.layer, | ||
| 26 | Layer.succeed(HostProcessPlatform, "win32"), | ||
| 27 | Layer.succeed(HostProcessArchitecture, "x64"), | ||
| 28 | ), | ||
| 29 | ), | ||
| 30 | ); | ||
| 31 | |||
| 32 | it.effect("spawns through the public adapter with the provided host references", () => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const adapter = yield* PtyAdapter; | ||
| 35 | const process = yield* adapter.spawn({ | ||
| 36 | shell: "powershell.exe", | ||
| 37 | args: ["-NoLogo"], | ||
| 38 | cwd: "C:\\workspace", | ||
| 39 | cols: 120, | ||
| 40 | rows: 40, | ||
| 41 | env: {}, | ||
| 42 | }); | ||
| 43 | |||
| 44 | assert.equal(process.pid, 42); | ||
| 45 | assert.equal(spawn.mock.calls.length, 1); | ||
| 46 | assert.deepEqual(spawn.mock.calls[0], [ | ||
| 47 | "powershell.exe", | ||
| 48 | ["-NoLogo"], | ||
| 49 | { | ||
| 50 | cwd: "C:\\workspace", | ||
| 51 | cols: 120, | ||
| 52 | rows: 40, | ||
| 53 | env: {}, | ||
| 54 | name: "xterm-color", | ||
| 55 | }, | ||
| 56 | ]); | ||
| 57 | }).pipe(Effect.provide(testLayer)), | ||
| 58 | ); | ||
apps/server/src/terminal/Layers/NodePTY.ts deleted-145| ... | @@ -1,145 +0,0 @@ | ||
| 1 | import { createRequire } from "node:module"; | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | import { PtyAdapter } from "../Services/PTY.ts"; | ||
| 9 | import { | ||
| 10 | PtySpawnError, | ||
| 11 | type PtyAdapterShape, | ||
| 12 | type PtyExitEvent, | ||
| 13 | type PtyProcess, | ||
| 14 | } from "../Services/PTY.ts"; | ||
| 15 | |||
| 16 | let didEnsureSpawnHelperExecutable = false; | ||
| 17 | |||
| 18 | const resolveNodePtySpawnHelperPath = Effect.gen(function* () { | ||
| 19 | const requireForNodePty = createRequire(import.meta.url); | ||
| 20 | const path = yield* Path.Path; | ||
| 21 | const fs = yield* FileSystem.FileSystem; | ||
| 22 | const platform = yield* HostProcessPlatform; | ||
| 23 | const architecture = yield* HostProcessArchitecture; | ||
| 24 | |||
| 25 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | ||
| 26 | const packageDir = path.dirname(packageJsonPath); | ||
| 27 | const candidates = [ | ||
| 28 | path.join(packageDir, "build", "Release", "spawn-helper"), | ||
| 29 | path.join(packageDir, "build", "Debug", "spawn-helper"), | ||
| 30 | path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), | ||
| 31 | ]; | ||
| 32 | |||
| 33 | for (const candidate of candidates) { | ||
| 34 | if (yield* fs.exists(candidate)) { | ||
| 35 | return candidate; | ||
| 36 | } | ||
| 37 | } | ||
| 38 | return null; | ||
| 39 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 40 | |||
| 41 | const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { | ||
| 42 | const fs = yield* FileSystem.FileSystem; | ||
| 43 | const platform = yield* HostProcessPlatform; | ||
| 44 | if (platform === "win32") return; | ||
| 45 | if (didEnsureSpawnHelperExecutable) return; | ||
| 46 | |||
| 47 | const helperPath = yield* resolveNodePtySpawnHelperPath; | ||
| 48 | if (!helperPath) return; | ||
| 49 | didEnsureSpawnHelperExecutable = true; | ||
| 50 | |||
| 51 | if (!(yield* fs.exists(helperPath))) { | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | |||
| 55 | // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. | ||
| 56 | yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); | ||
| 57 | }); | ||
| 58 | |||
| 59 | class NodePtyProcess implements PtyProcess { | ||
| 60 | private readonly process: import("node-pty").IPty; | ||
| 61 | |||
| 62 | constructor(process: import("node-pty").IPty) { | ||
| 63 | this.process = process; | ||
| 64 | } | ||
| 65 | |||
| 66 | get pid(): number { | ||
| 67 | return this.process.pid; | ||
| 68 | } | ||
| 69 | |||
| 70 | write(data: string): void { | ||
| 71 | this.process.write(data); | ||
| 72 | } | ||
| 73 | |||
| 74 | resize(cols: number, rows: number): void { | ||
| 75 | this.process.resize(cols, rows); | ||
| 76 | } | ||
| 77 | |||
| 78 | kill(signal?: string): void { | ||
| 79 | this.process.kill(signal); | ||
| 80 | } | ||
| 81 | |||
| 82 | onData(callback: (data: string) => void): () => void { | ||
| 83 | const disposable = this.process.onData(callback); | ||
| 84 | return () => { | ||
| 85 | disposable.dispose(); | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | |||
| 89 | onExit(callback: (event: PtyExitEvent) => void): () => void { | ||
| 90 | const disposable = this.process.onExit((event) => { | ||
| 91 | callback({ | ||
| 92 | exitCode: event.exitCode, | ||
| 93 | signal: event.signal ?? null, | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | return () => { | ||
| 97 | disposable.dispose(); | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | export const layer = Layer.effect( | ||
| 103 | PtyAdapter, | ||
| 104 | Effect.gen(function* () { | ||
| 105 | const fs = yield* FileSystem.FileSystem; | ||
| 106 | const path = yield* Path.Path; | ||
| 107 | const platform = yield* HostProcessPlatform; | ||
| 108 | const architecture = yield* HostProcessArchitecture; | ||
| 109 | |||
| 110 | const nodePty = yield* Effect.promise(() => import("node-pty")); | ||
| 111 | |||
| 112 | const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( | ||
| 113 | ensureNodePtySpawnHelperExecutable().pipe( | ||
| 114 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 115 | Effect.provideService(Path.Path, path), | ||
| 116 | Effect.provideService(HostProcessPlatform, platform), | ||
| 117 | Effect.provideService(HostProcessArchitecture, architecture), | ||
| 118 | Effect.orElseSucceed(() => undefined), | ||
| 119 | ), | ||
| 120 | ); | ||
| 121 | |||
| 122 | return { | ||
| 123 | spawn: Effect.fn(function* (input) { | ||
| 124 | yield* ensureNodePtySpawnHelperExecutableCached; | ||
| 125 | const ptyProcess = yield* Effect.try({ | ||
| 126 | try: () => | ||
| 127 | nodePty.spawn(input.shell, input.args ?? [], { | ||
| 128 | cwd: input.cwd, | ||
| 129 | cols: input.cols, | ||
| 130 | rows: input.rows, | ||
| 131 | env: input.env, | ||
| 132 | name: platform === "win32" ? "xterm-color" : "xterm-256color", | ||
| 133 | }), | ||
| 134 | catch: (cause) => | ||
| 135 | new PtySpawnError({ | ||
| 136 | adapter: "node-pty", | ||
| 137 | message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", | ||
| 138 | cause, | ||
| 139 | }), | ||
| 140 | }); | ||
| 141 | return new NodePtyProcess(ptyProcess); | ||
| 142 | }), | ||
| 143 | } satisfies PtyAdapterShape; | ||
| 144 | }), | ||
| 145 | ); | ||
apps/server/src/terminal/Manager.test.ts created+1550| ... | @@ -0,0 +1,1550 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | DEFAULT_TERMINAL_ID, | ||
| 5 | type TerminalAttachStreamEvent, | ||
| 6 | type TerminalEvent, | ||
| 7 | type TerminalMetadataStreamEvent, | ||
| 8 | type TerminalOpenInput, | ||
| 9 | type TerminalRestartInput, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 12 | import * as Data from "effect/Data"; | ||
| 13 | import * as Duration from "effect/Duration"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Encoding from "effect/Encoding"; | ||
| 16 | import * as Exit from "effect/Exit"; | ||
| 17 | import * as Fiber from "effect/Fiber"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Layer from "effect/Layer"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import * as PlatformError from "effect/PlatformError"; | ||
| 22 | import * as Path from "effect/Path"; | ||
| 23 | import * as Ref from "effect/Ref"; | ||
| 24 | import * as Schedule from "effect/Schedule"; | ||
| 25 | import * as Scope from "effect/Scope"; | ||
| 26 | import * as TestClock from "effect/testing/TestClock"; | ||
| 27 | import { expect } from "vite-plus/test"; | ||
| 28 | |||
| 29 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 30 | import * as TerminalManager from "./Manager.ts"; | ||
| 31 | import * as PtyAdapter from "./PtyAdapter.ts"; | ||
| 32 | |||
| 33 | class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ | ||
| 34 | readonly message: string; | ||
| 35 | }> {} | ||
| 36 | |||
| 37 | class FakePtyProcess implements PtyAdapter.PtyProcess { | ||
| 38 | readonly writes: string[] = []; | ||
| 39 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; | ||
| 40 | readonly killSignals: Array<string | undefined> = []; | ||
| 41 | readonly pid: number; | ||
| 42 | private readonly dataListeners = new Set<(data: string) => void>(); | ||
| 43 | private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); | ||
| 44 | killed = false; | ||
| 45 | |||
| 46 | constructor(pid: number) { | ||
| 47 | this.pid = pid; | ||
| 48 | } | ||
| 49 | |||
| 50 | write(data: string): void { | ||
| 51 | this.writes.push(data); | ||
| 52 | } | ||
| 53 | |||
| 54 | resize(cols: number, rows: number): void { | ||
| 55 | this.resizeCalls.push({ cols, rows }); | ||
| 56 | } | ||
| 57 | |||
| 58 | kill(signal?: string): void { | ||
| 59 | this.killed = true; | ||
| 60 | this.killSignals.push(signal); | ||
| 61 | } | ||
| 62 | |||
| 63 | onData(callback: (data: string) => void): () => void { | ||
| 64 | this.dataListeners.add(callback); | ||
| 65 | return () => { | ||
| 66 | this.dataListeners.delete(callback); | ||
| 67 | }; | ||
| 68 | } | ||
| 69 | |||
| 70 | onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { | ||
| 71 | this.exitListeners.add(callback); | ||
| 72 | return () => { | ||
| 73 | this.exitListeners.delete(callback); | ||
| 74 | }; | ||
| 75 | } | ||
| 76 | |||
| 77 | emitData(data: string): void { | ||
| 78 | for (const listener of this.dataListeners) { | ||
| 79 | listener(data); | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | emitExit(event: PtyAdapter.PtyExitEvent): void { | ||
| 84 | for (const listener of this.exitListeners) { | ||
| 85 | listener(event); | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | class FakePtyAdapter { | ||
| 91 | readonly spawnInputs: PtyAdapter.PtySpawnInput[] = []; | ||
| 92 | readonly processes: FakePtyProcess[] = []; | ||
| 93 | readonly spawnFailures: Error[] = []; | ||
| 94 | private readonly mode: "sync" | "async"; | ||
| 95 | private nextPid = 9000; | ||
| 96 | |||
| 97 | constructor(mode: "sync" | "async" = "sync") { | ||
| 98 | this.mode = mode; | ||
| 99 | } | ||
| 100 | |||
| 101 | spawn( | ||
| 102 | input: PtyAdapter.PtySpawnInput, | ||
| 103 | ): Effect.Effect<PtyAdapter.PtyProcess, PtyAdapter.PtySpawnError> { | ||
| 104 | this.spawnInputs.push(input); | ||
| 105 | const failure = this.spawnFailures.shift(); | ||
| 106 | if (failure) { | ||
| 107 | return Effect.fail( | ||
| 108 | new PtyAdapter.PtySpawnError({ | ||
| 109 | adapter: "fake", | ||
| 110 | shell: input.shell, | ||
| 111 | cause: failure, | ||
| 112 | }), | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | const process = new FakePtyProcess(this.nextPid++); | ||
| 116 | this.processes.push(process); | ||
| 117 | if (this.mode === "async") { | ||
| 118 | return Effect.tryPromise({ | ||
| 119 | try: async () => process, | ||
| 120 | catch: (cause) => | ||
| 121 | new PtyAdapter.PtySpawnError({ | ||
| 122 | adapter: "fake", | ||
| 123 | shell: input.shell, | ||
| 124 | cause, | ||
| 125 | }), | ||
| 126 | }); | ||
| 127 | } | ||
| 128 | return Effect.succeed(process); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | const waitFor = <E, R>( | ||
| 133 | predicate: Effect.Effect<boolean, E, R>, | ||
| 134 | timeout: Duration.Input = 800, | ||
| 135 | ): Effect.Effect<void, WaitForConditionError | E, R> => | ||
| 136 | predicate.pipe( | ||
| 137 | Effect.filterOrFail( | ||
| 138 | (done) => done, | ||
| 139 | () => new WaitForConditionError({ message: "Condition not met" }), | ||
| 140 | ), | ||
| 141 | Effect.retry(Schedule.spaced("15 millis")), | ||
| 142 | Effect.timeoutOption(timeout), | ||
| 143 | Effect.flatMap((result) => | ||
| 144 | Option.match(result, { | ||
| 145 | onNone: () => | ||
| 146 | Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), | ||
| 147 | onSome: () => Effect.void, | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | ); | ||
| 151 | |||
| 152 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { | ||
| 153 | return { | ||
| 154 | threadId: "thread-1", | ||
| 155 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 156 | cwd: process.cwd(), | ||
| 157 | cols: 100, | ||
| 158 | rows: 24, | ||
| 159 | ...overrides, | ||
| 160 | }; | ||
| 161 | } | ||
| 162 | |||
| 163 | function restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput { | ||
| 164 | return { | ||
| 165 | threadId: "thread-1", | ||
| 166 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 167 | cwd: process.cwd(), | ||
| 168 | cols: 100, | ||
| 169 | rows: 24, | ||
| 170 | ...overrides, | ||
| 171 | }; | ||
| 172 | } | ||
| 173 | |||
| 174 | const historyLogPath = (logsDir: string, threadId = "thread-1") => | ||
| 175 | Effect.service(Path.Path).pipe( | ||
| 176 | Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), | ||
| 177 | ); | ||
| 178 | |||
| 179 | const multiTerminalHistoryLogPath = ( | ||
| 180 | logsDir: string, | ||
| 181 | threadId = "thread-1", | ||
| 182 | terminalId = DEFAULT_TERMINAL_ID, | ||
| 183 | ) => | ||
| 184 | Effect.service(Path.Path).pipe( | ||
| 185 | Effect.map(({ join }) => { | ||
| 186 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | ||
| 187 | return join( | ||
| 188 | logsDir, | ||
| 189 | terminalId === DEFAULT_TERMINAL_ID | ||
| 190 | ? `${threadPart}.log` | ||
| 191 | : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, | ||
| 192 | ); | ||
| 193 | }), | ||
| 194 | ); | ||
| 195 | |||
| 196 | interface CreateManagerOptions { | ||
| 197 | shellResolver?: () => string; | ||
| 198 | env?: NodeJS.ProcessEnv; | ||
| 199 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ | ||
| 200 | readonly hasRunningSubprocess: boolean; | ||
| 201 | readonly childCommand: string | null; | ||
| 202 | readonly processIds: ReadonlyArray<number>; | ||
| 203 | }>; | ||
| 204 | subprocessPollIntervalMs?: number; | ||
| 205 | processKillGraceMs?: number; | ||
| 206 | maxRetainedInactiveSessions?: number; | ||
| 207 | ptyAdapter?: FakePtyAdapter; | ||
| 208 | } | ||
| 209 | |||
| 210 | interface ManagerFixture { | ||
| 211 | readonly baseDir: string; | ||
| 212 | readonly logsDir: string; | ||
| 213 | readonly ptyAdapter: FakePtyAdapter; | ||
| 214 | readonly manager: TerminalManager.TerminalManager["Service"]; | ||
| 215 | readonly getEvents: Effect.Effect<ReadonlyArray<TerminalEvent>>; | ||
| 216 | } | ||
| 217 | |||
| 218 | const createManager = ( | ||
| 219 | historyLineLimit = 5, | ||
| 220 | options: CreateManagerOptions = {}, | ||
| 221 | ): Effect.Effect< | ||
| 222 | ManagerFixture, | ||
| 223 | PlatformError.PlatformError, | ||
| 224 | FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner | ||
| 225 | > => | ||
| 226 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | const { join } = yield* Path.Path; | ||
| 229 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); | ||
| 230 | const logsDir = join(baseDir, "userdata", "logs", "terminals"); | ||
| 231 | const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); | ||
| 232 | |||
| 233 | const manager = yield* TerminalManager.makeWithOptions({ | ||
| 234 | logsDir, | ||
| 235 | historyLineLimit, | ||
| 236 | ptyAdapter, | ||
| 237 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), | ||
| 238 | ...(options.env !== undefined ? { env: options.env } : {}), | ||
| 239 | ...(options.subprocessInspector !== undefined | ||
| 240 | ? { subprocessInspector: options.subprocessInspector } | ||
| 241 | : {}), | ||
| 242 | ...(options.subprocessPollIntervalMs !== undefined | ||
| 243 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | ||
| 244 | : {}), | ||
| 245 | processKillGraceMs: options.processKillGraceMs ?? 1, | ||
| 246 | ...(options.maxRetainedInactiveSessions !== undefined | ||
| 247 | ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } | ||
| 248 | : {}), | ||
| 249 | }); | ||
| 250 | const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); | ||
| 251 | const unsubscribe = yield* manager.subscribe((event) => | ||
| 252 | Ref.update(eventsRef, (events) => [...events, event]), | ||
| 253 | ); | ||
| 254 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 255 | |||
| 256 | return { | ||
| 257 | baseDir, | ||
| 258 | logsDir, | ||
| 259 | join, | ||
| 260 | ptyAdapter, | ||
| 261 | manager, | ||
| 262 | getEvents: Ref.get(eventsRef), | ||
| 263 | }; | ||
| 264 | }), | ||
| 265 | ); | ||
| 266 | |||
| 267 | const withHostPlatform = (platform: NodeJS.Platform) => | ||
| 268 | Layer.succeed(HostProcessPlatform, platform); | ||
| 269 | |||
| 270 | it.layer( | ||
| 271 | Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), | ||
| 272 | { excludeTestServices: true }, | ||
| 273 | )("TerminalManager", (it) => { | ||
| 274 | it.effect("spawns lazily and reuses running terminal per thread", () => | ||
| 275 | Effect.gen(function* () { | ||
| 276 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 277 | const [first, second] = yield* Effect.all( | ||
| 278 | [manager.open(openInput()), manager.open(openInput())], | ||
| 279 | { concurrency: "unbounded" }, | ||
| 280 | ); | ||
| 281 | const third = yield* manager.open(openInput()); | ||
| 282 | |||
| 283 | assert.equal(first.threadId, "thread-1"); | ||
| 284 | assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); | ||
| 285 | assert.equal(second.threadId, "thread-1"); | ||
| 286 | assert.equal(third.threadId, "thread-1"); | ||
| 287 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | |||
| 291 | it.effect("attaches to running sessions without restarting them", () => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 294 | |||
| 295 | yield* manager.open(openInput()); | ||
| 296 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 297 | const unsubscribe = yield* manager.attachStream( | ||
| 298 | { | ||
| 299 | threadId: "thread-1", | ||
| 300 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 301 | cols: 100, | ||
| 302 | rows: 40, | ||
| 303 | }, | ||
| 304 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 305 | ); | ||
| 306 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 307 | |||
| 308 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 309 | expect(snapshot).toBeDefined(); | ||
| 310 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 311 | assert.equal(snapshot.snapshot.threadId, "thread-1"); | ||
| 312 | assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); | ||
| 313 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | |||
| 317 | it.effect("keeps attach streams live when a terminal id is closed and reopened", () => | ||
| 318 | Effect.gen(function* () { | ||
| 319 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 320 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 321 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 322 | Ref.update(attachEvents, (events) => [...events, event]), | ||
| 323 | ); | ||
| 324 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 325 | |||
| 326 | yield* manager.close({ | ||
| 327 | threadId: "thread-1", | ||
| 328 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 329 | deleteHistory: true, | ||
| 330 | }); | ||
| 331 | yield* manager.open(openInput()); | ||
| 332 | |||
| 333 | const events = yield* Ref.get(attachEvents); | ||
| 334 | expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); | ||
| 335 | expect( | ||
| 336 | events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), | ||
| 337 | ).toEqual(["running", "running"]); | ||
| 338 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 339 | }), | ||
| 340 | ); | ||
| 341 | |||
| 342 | it.effect("attaches to exited sessions without restarting them", () => | ||
| 343 | Effect.gen(function* () { | ||
| 344 | const { manager, ptyAdapter, getEvents } = yield* createManager(); | ||
| 345 | |||
| 346 | yield* manager.open(openInput()); | ||
| 347 | const process = ptyAdapter.processes[0]; | ||
| 348 | expect(process).toBeDefined(); | ||
| 349 | if (!process) return; | ||
| 350 | |||
| 351 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 352 | |||
| 353 | yield* waitFor( | ||
| 354 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 355 | "1200 millis", | ||
| 356 | ); | ||
| 357 | |||
| 358 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 359 | const unsubscribe = yield* manager.attachStream( | ||
| 360 | openInput({ | ||
| 361 | env: { | ||
| 362 | T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", | ||
| 363 | }, | ||
| 364 | worktreePath: "/tmp/should-not-restart", | ||
| 365 | }), | ||
| 366 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 367 | ); | ||
| 368 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 369 | |||
| 370 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 371 | expect(snapshot).toBeDefined(); | ||
| 372 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 373 | assert.equal(snapshot.snapshot.status, "exited"); | ||
| 374 | assert.equal(snapshot.snapshot.worktreePath, null); | ||
| 375 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 376 | }), | ||
| 377 | ); | ||
| 378 | |||
| 379 | it.effect("restarts inactive sessions from attach only when requested", () => | ||
| 380 | Effect.gen(function* () { | ||
| 381 | const { manager, ptyAdapter, getEvents } = yield* createManager(); | ||
| 382 | |||
| 383 | yield* manager.open(openInput()); | ||
| 384 | const process = ptyAdapter.processes[0]; | ||
| 385 | expect(process).toBeDefined(); | ||
| 386 | if (!process) return; | ||
| 387 | |||
| 388 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 389 | |||
| 390 | yield* waitFor( | ||
| 391 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 392 | "1200 millis", | ||
| 393 | ); | ||
| 394 | |||
| 395 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 396 | const unsubscribe = yield* manager.attachStream( | ||
| 397 | { | ||
| 398 | ...openInput({ | ||
| 399 | env: { | ||
| 400 | T3CODE_WORKTREE_PATH: "/tmp/restart-requested", | ||
| 401 | }, | ||
| 402 | worktreePath: "/tmp/restart-requested", | ||
| 403 | }), | ||
| 404 | restartIfNotRunning: true, | ||
| 405 | }, | ||
| 406 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 407 | ); | ||
| 408 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 409 | |||
| 410 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 411 | expect(snapshot).toBeDefined(); | ||
| 412 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 413 | assert.equal(snapshot.snapshot.status, "running"); | ||
| 414 | assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); | ||
| 415 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 416 | }), | ||
| 417 | ); | ||
| 418 | |||
| 419 | const makeDirectory = (filePath: string) => | ||
| 420 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | ||
| 421 | fs.makeDirectory(filePath, { recursive: true }), | ||
| 422 | ); | ||
| 423 | |||
| 424 | const chmod = (filePath: string, mode: number) => | ||
| 425 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); | ||
| 426 | |||
| 427 | const pathExists = (filePath: string) => | ||
| 428 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); | ||
| 429 | |||
| 430 | const readFileString = (filePath: string) => | ||
| 431 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); | ||
| 432 | |||
| 433 | const writeFileString = (filePath: string, contents: string) => | ||
| 434 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | ||
| 435 | fs.writeFileString(filePath, contents), | ||
| 436 | ); | ||
| 437 | |||
| 438 | it.effect("preserves non-notFound cwd stat failures", () => | ||
| 439 | Effect.gen(function* () { | ||
| 440 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 441 | |||
| 442 | const path = yield* Path.Path; | ||
| 443 | |||
| 444 | const { manager, baseDir } = yield* createManager(); | ||
| 445 | const blockedRoot = path.join(baseDir, "blocked-root"); | ||
| 446 | const blockedCwd = path.join(blockedRoot, "cwd"); | ||
| 447 | yield* makeDirectory(blockedCwd); | ||
| 448 | yield* chmod(blockedRoot, 0o000); | ||
| 449 | |||
| 450 | const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( | ||
| 451 | Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), | ||
| 452 | ); | ||
| 453 | |||
| 454 | expect(error).toMatchObject({ | ||
| 455 | _tag: "TerminalCwdError", | ||
| 456 | cwd: blockedCwd, | ||
| 457 | reason: "statFailed", | ||
| 458 | }); | ||
| 459 | }), | ||
| 460 | ); | ||
| 461 | |||
| 462 | it.effect("supports asynchronous PTY spawn effects", () => | ||
| 463 | Effect.gen(function* () { | ||
| 464 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 465 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 466 | }); | ||
| 467 | |||
| 468 | const snapshot = yield* manager.open(openInput()); | ||
| 469 | |||
| 470 | assert.equal(snapshot.status, "running"); | ||
| 471 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 472 | expect(ptyAdapter.processes).toHaveLength(1); | ||
| 473 | }), | ||
| 474 | ); | ||
| 475 | |||
| 476 | it.effect("forwards write and resize to active pty process", () => | ||
| 477 | Effect.gen(function* () { | ||
| 478 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 479 | yield* manager.open(openInput()); | ||
| 480 | const process = ptyAdapter.processes[0]; | ||
| 481 | expect(process).toBeDefined(); | ||
| 482 | if (!process) return; | ||
| 483 | |||
| 484 | yield* manager.write({ | ||
| 485 | threadId: "thread-1", | ||
| 486 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 487 | data: "ls\n", | ||
| 488 | }); | ||
| 489 | yield* manager.resize({ | ||
| 490 | threadId: "thread-1", | ||
| 491 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 492 | cols: 120, | ||
| 493 | rows: 30, | ||
| 494 | }); | ||
| 495 | |||
| 496 | expect(process.writes).toEqual(["ls\n"]); | ||
| 497 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | ||
| 498 | }), | ||
| 499 | ); | ||
| 500 | |||
| 501 | it.effect("ignores delayed resize requests after a terminal closes", () => | ||
| 502 | Effect.gen(function* () { | ||
| 503 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 504 | yield* manager.open(openInput()); | ||
| 505 | const process = ptyAdapter.processes[0]; | ||
| 506 | expect(process).toBeDefined(); | ||
| 507 | if (!process) return; | ||
| 508 | |||
| 509 | yield* manager.close({ | ||
| 510 | threadId: "thread-1", | ||
| 511 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 512 | deleteHistory: true, | ||
| 513 | }); | ||
| 514 | yield* manager.resize({ | ||
| 515 | threadId: "thread-1", | ||
| 516 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 517 | cols: 120, | ||
| 518 | rows: 30, | ||
| 519 | }); | ||
| 520 | |||
| 521 | expect(process.resizeCalls).toEqual([]); | ||
| 522 | }), | ||
| 523 | ); | ||
| 524 | |||
| 525 | it.effect("resizes running terminal on open when a different size is requested", () => | ||
| 526 | Effect.gen(function* () { | ||
| 527 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 528 | yield* manager.open(openInput({ cols: 100, rows: 24 })); | ||
| 529 | const process = ptyAdapter.processes[0]; | ||
| 530 | expect(process).toBeDefined(); | ||
| 531 | if (!process) return; | ||
| 532 | |||
| 533 | const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); | ||
| 534 | |||
| 535 | assert.equal(reopened.status, "running"); | ||
| 536 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | ||
| 537 | }), | ||
| 538 | ); | ||
| 539 | |||
| 540 | it.effect("supports multiple terminals per thread independently", () => | ||
| 541 | Effect.gen(function* () { | ||
| 542 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 543 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 544 | yield* manager.open(openInput({ terminalId: "term-2" })); | ||
| 545 | |||
| 546 | const first = ptyAdapter.processes[0]; | ||
| 547 | const second = ptyAdapter.processes[1]; | ||
| 548 | expect(first).toBeDefined(); | ||
| 549 | expect(second).toBeDefined(); | ||
| 550 | if (!first || !second) return; | ||
| 551 | |||
| 552 | yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); | ||
| 553 | yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); | ||
| 554 | |||
| 555 | expect(first.writes).toEqual(["pwd\n"]); | ||
| 556 | expect(second.writes).toEqual(["ls\n"]); | ||
| 557 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 558 | }), | ||
| 559 | ); | ||
| 560 | |||
| 561 | it.effect("clears transcript and emits cleared event", () => | ||
| 562 | Effect.gen(function* () { | ||
| 563 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); | ||
| 564 | const path = yield* Path.Path; | ||
| 565 | yield* manager.open(openInput()); | ||
| 566 | const process = ptyAdapter.processes[0]; | ||
| 567 | expect(process).toBeDefined(); | ||
| 568 | if (!process) return; | ||
| 569 | |||
| 570 | process.emitData("hello\n"); | ||
| 571 | yield* waitFor( | ||
| 572 | historyLogPath(logsDir).pipe( | ||
| 573 | Effect.provideService(Path.Path, path), | ||
| 574 | Effect.flatMap(pathExists), | ||
| 575 | ), | ||
| 576 | ); | ||
| 577 | yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); | ||
| 578 | yield* waitFor( | ||
| 579 | historyLogPath(logsDir).pipe( | ||
| 580 | Effect.provideService(Path.Path, path), | ||
| 581 | Effect.flatMap(readFileString), | ||
| 582 | Effect.map((text) => text === ""), | ||
| 583 | ), | ||
| 584 | ); | ||
| 585 | |||
| 586 | const events = yield* getEvents; | ||
| 587 | expect(events.some((event) => event.type === "cleared")).toBe(true); | ||
| 588 | expect( | ||
| 589 | events.some( | ||
| 590 | (event) => | ||
| 591 | event.type === "cleared" && | ||
| 592 | event.threadId === "thread-1" && | ||
| 593 | event.terminalId === DEFAULT_TERMINAL_ID, | ||
| 594 | ), | ||
| 595 | ).toBe(true); | ||
| 596 | }), | ||
| 597 | ); | ||
| 598 | |||
| 599 | it.effect("restarts terminal with empty transcript and respawns pty", () => | ||
| 600 | Effect.gen(function* () { | ||
| 601 | const { manager, ptyAdapter, logsDir } = yield* createManager(); | ||
| 602 | yield* manager.open(openInput()); | ||
| 603 | const firstProcess = ptyAdapter.processes[0]; | ||
| 604 | expect(firstProcess).toBeDefined(); | ||
| 605 | if (!firstProcess) return; | ||
| 606 | firstProcess.emitData("before restart\n"); | ||
| 607 | const path = yield* Path.Path; | ||
| 608 | yield* waitFor( | ||
| 609 | historyLogPath(logsDir).pipe( | ||
| 610 | Effect.provideService(Path.Path, path), | ||
| 611 | Effect.flatMap(pathExists), | ||
| 612 | ), | ||
| 613 | ); | ||
| 614 | |||
| 615 | const snapshot = yield* manager.restart(restartInput()); | ||
| 616 | assert.equal(snapshot.history, ""); | ||
| 617 | assert.equal(snapshot.status, "running"); | ||
| 618 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 619 | yield* waitFor( | ||
| 620 | historyLogPath(logsDir).pipe( | ||
| 621 | Effect.provideService(Path.Path, path), | ||
| 622 | Effect.flatMap(readFileString), | ||
| 623 | Effect.map((text) => text === ""), | ||
| 624 | ), | ||
| 625 | ); | ||
| 626 | }), | ||
| 627 | ); | ||
| 628 | |||
| 629 | it.effect("restarts a running session when open is called with a different cwd", () => | ||
| 630 | Effect.gen(function* () { | ||
| 631 | const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); | ||
| 632 | const path = yield* Path.Path; | ||
| 633 | const originalCwd = path.join(baseDir, "original"); | ||
| 634 | const differentCwd = path.join(baseDir, "different"); | ||
| 635 | yield* makeDirectory(originalCwd); | ||
| 636 | yield* makeDirectory(differentCwd); | ||
| 637 | |||
| 638 | yield* manager.open(openInput({ cwd: originalCwd })); | ||
| 639 | const firstProcess = ptyAdapter.processes[0]; | ||
| 640 | expect(firstProcess).toBeDefined(); | ||
| 641 | if (!firstProcess) return; | ||
| 642 | |||
| 643 | firstProcess.emitData("before reopen\n"); | ||
| 644 | const logPath = yield* historyLogPath(logsDir); | ||
| 645 | yield* waitFor(pathExists(logPath)); | ||
| 646 | |||
| 647 | const reopened = yield* manager.open(openInput({ cwd: differentCwd })); | ||
| 648 | |||
| 649 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 650 | assert.equal(firstProcess.killed, true); | ||
| 651 | assert.equal(reopened.cwd, differentCwd); | ||
| 652 | assert.equal(reopened.history, ""); | ||
| 653 | yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); | ||
| 654 | }), | ||
| 655 | ); | ||
| 656 | |||
| 657 | it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => | ||
| 658 | Effect.gen(function* () { | ||
| 659 | const { manager, getEvents, baseDir } = yield* createManager(); | ||
| 660 | const path = yield* Path.Path; | ||
| 661 | const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); | ||
| 662 | const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); | ||
| 663 | yield* makeDirectory(firstWorktreePath); | ||
| 664 | yield* makeDirectory(secondWorktreePath); | ||
| 665 | const startedSnapshot = yield* manager.open( | ||
| 666 | openInput({ | ||
| 667 | cwd: firstWorktreePath, | ||
| 668 | worktreePath: firstWorktreePath, | ||
| 669 | }), | ||
| 670 | ); | ||
| 671 | const restartedSnapshot = yield* manager.restart( | ||
| 672 | restartInput({ | ||
| 673 | cwd: secondWorktreePath, | ||
| 674 | worktreePath: secondWorktreePath, | ||
| 675 | }), | ||
| 676 | ); | ||
| 677 | |||
| 678 | assert.equal(startedSnapshot.worktreePath, firstWorktreePath); | ||
| 679 | assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); | ||
| 680 | |||
| 681 | const events = yield* getEvents; | ||
| 682 | const startedEvent = events.find( | ||
| 683 | (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started", | ||
| 684 | ); | ||
| 685 | const restartedEvent = events.find( | ||
| 686 | (event): event is Extract<TerminalEvent, { type: "restarted" }> => | ||
| 687 | event.type === "restarted", | ||
| 688 | ); | ||
| 689 | |||
| 690 | assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); | ||
| 691 | assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); | ||
| 692 | }), | ||
| 693 | ); | ||
| 694 | |||
| 695 | it.effect("preserves worktree metadata when reopening an exited session", () => | ||
| 696 | Effect.gen(function* () { | ||
| 697 | const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); | ||
| 698 | const path = yield* Path.Path; | ||
| 699 | const worktreePath = path.join(baseDir, "worktrees", "feature-a"); | ||
| 700 | yield* makeDirectory(worktreePath); | ||
| 701 | |||
| 702 | yield* manager.open( | ||
| 703 | openInput({ | ||
| 704 | cwd: worktreePath, | ||
| 705 | worktreePath, | ||
| 706 | }), | ||
| 707 | ); | ||
| 708 | |||
| 709 | const process = ptyAdapter.processes[0]; | ||
| 710 | expect(process).toBeDefined(); | ||
| 711 | if (!process) return; | ||
| 712 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 713 | |||
| 714 | yield* waitFor( | ||
| 715 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 716 | ); | ||
| 717 | |||
| 718 | const reopenedSnapshot = yield* manager.open( | ||
| 719 | openInput({ | ||
| 720 | cwd: worktreePath, | ||
| 721 | worktreePath, | ||
| 722 | }), | ||
| 723 | ); | ||
| 724 | |||
| 725 | assert.equal(reopenedSnapshot.worktreePath, worktreePath); | ||
| 726 | |||
| 727 | const events = yield* getEvents; | ||
| 728 | const reopenedEvent = events | ||
| 729 | .toReversed() | ||
| 730 | .find( | ||
| 731 | (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started", | ||
| 732 | ); | ||
| 733 | |||
| 734 | assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); | ||
| 735 | }), | ||
| 736 | ); | ||
| 737 | |||
| 738 | it.effect("emits exited event and reopens with clean transcript after exit", () => | ||
| 739 | Effect.gen(function* () { | ||
| 740 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); | ||
| 741 | const path = yield* Path.Path; | ||
| 742 | yield* manager.open(openInput()); | ||
| 743 | const process = ptyAdapter.processes[0]; | ||
| 744 | expect(process).toBeDefined(); | ||
| 745 | if (!process) return; | ||
| 746 | process.emitData("old data\n"); | ||
| 747 | yield* waitFor( | ||
| 748 | historyLogPath(logsDir).pipe( | ||
| 749 | Effect.provideService(Path.Path, path), | ||
| 750 | Effect.flatMap(pathExists), | ||
| 751 | ), | ||
| 752 | ); | ||
| 753 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 754 | |||
| 755 | yield* waitFor( | ||
| 756 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 757 | ); | ||
| 758 | const reopened = yield* manager.open(openInput()); | ||
| 759 | |||
| 760 | assert.equal(reopened.history, ""); | ||
| 761 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 762 | expect( | ||
| 763 | yield* historyLogPath(logsDir).pipe( | ||
| 764 | Effect.provideService(Path.Path, path), | ||
| 765 | Effect.flatMap(readFileString), | ||
| 766 | ), | ||
| 767 | ).toBe(""); | ||
| 768 | }), | ||
| 769 | ); | ||
| 770 | |||
| 771 | it.effect("ignores trailing writes after terminal exit", () => | ||
| 772 | Effect.gen(function* () { | ||
| 773 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 774 | yield* manager.open(openInput()); | ||
| 775 | const process = ptyAdapter.processes[0]; | ||
| 776 | expect(process).toBeDefined(); | ||
| 777 | if (!process) return; | ||
| 778 | |||
| 779 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 780 | |||
| 781 | yield* manager.write({ | ||
| 782 | threadId: "thread-1", | ||
| 783 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 784 | data: "\r", | ||
| 785 | }); | ||
| 786 | expect(process.writes).toEqual([]); | ||
| 787 | }), | ||
| 788 | ); | ||
| 789 | |||
| 790 | it.effect("emits subprocess activity events when child-process state changes", () => | ||
| 791 | Effect.gen(function* () { | ||
| 792 | let inspect: { | ||
| 793 | readonly hasRunningSubprocess: boolean; | ||
| 794 | readonly childCommand: string | null; | ||
| 795 | readonly processIds: ReadonlyArray<number>; | ||
| 796 | } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 797 | const { manager, getEvents } = yield* createManager(5, { | ||
| 798 | subprocessInspector: () => Effect.succeed(inspect), | ||
| 799 | subprocessPollIntervalMs: 20, | ||
| 800 | }); | ||
| 801 | |||
| 802 | yield* manager.open(openInput()); | ||
| 803 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); | ||
| 804 | |||
| 805 | inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; | ||
| 806 | yield* waitFor( | ||
| 807 | Effect.map(getEvents, (events) => | ||
| 808 | events.some( | ||
| 809 | (event) => | ||
| 810 | event.type === "activity" && | ||
| 811 | event.hasRunningSubprocess === true && | ||
| 812 | event.label === "vim", | ||
| 813 | ), | ||
| 814 | ), | ||
| 815 | "1200 millis", | ||
| 816 | ); | ||
| 817 | |||
| 818 | inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 819 | yield* waitFor( | ||
| 820 | Effect.map(getEvents, (events) => | ||
| 821 | events.some( | ||
| 822 | (event) => | ||
| 823 | event.type === "activity" && | ||
| 824 | event.hasRunningSubprocess === false && | ||
| 825 | event.label === "Terminal 1", | ||
| 826 | ), | ||
| 827 | ), | ||
| 828 | "1200 millis", | ||
| 829 | ); | ||
| 830 | }), | ||
| 831 | ); | ||
| 832 | |||
| 833 | it.effect("does not invoke subprocess polling until a terminal session is running", () => | ||
| 834 | Effect.gen(function* () { | ||
| 835 | let checks = 0; | ||
| 836 | const { manager } = yield* createManager(5, { | ||
| 837 | subprocessInspector: () => { | ||
| 838 | checks += 1; | ||
| 839 | return Effect.succeed({ | ||
| 840 | hasRunningSubprocess: false, | ||
| 841 | childCommand: null, | ||
| 842 | processIds: [], | ||
| 843 | }); | ||
| 844 | }, | ||
| 845 | subprocessPollIntervalMs: 20, | ||
| 846 | }); | ||
| 847 | |||
| 848 | yield* Effect.sleep("80 millis"); | ||
| 849 | assert.equal(checks, 0); | ||
| 850 | |||
| 851 | yield* manager.open(openInput()); | ||
| 852 | yield* waitFor( | ||
| 853 | Effect.sync(() => checks > 0), | ||
| 854 | "1200 millis", | ||
| 855 | ); | ||
| 856 | }), | ||
| 857 | ); | ||
| 858 | |||
| 859 | it.effect("caps persisted history to configured line limit", () => | ||
| 860 | Effect.gen(function* () { | ||
| 861 | const { manager, ptyAdapter } = yield* createManager(3); | ||
| 862 | yield* manager.open(openInput()); | ||
| 863 | const process = ptyAdapter.processes[0]; | ||
| 864 | expect(process).toBeDefined(); | ||
| 865 | if (!process) return; | ||
| 866 | |||
| 867 | process.emitData("line1\nline2\nline3\nline4\n"); | ||
| 868 | yield* manager.close({ threadId: "thread-1" }); | ||
| 869 | |||
| 870 | const reopened = yield* manager.open(openInput()); | ||
| 871 | const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); | ||
| 872 | expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); | ||
| 873 | }), | ||
| 874 | ); | ||
| 875 | |||
| 876 | it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => | ||
| 877 | Effect.gen(function* () { | ||
| 878 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 879 | yield* manager.open(openInput()); | ||
| 880 | const process = ptyAdapter.processes[0]; | ||
| 881 | expect(process).toBeDefined(); | ||
| 882 | if (!process) return; | ||
| 883 | |||
| 884 | process.emitData("prompt "); | ||
| 885 | process.emitData("\u001b[32mok\u001b[0m "); | ||
| 886 | process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); | ||
| 887 | process.emitData("\u001b[1;1R"); | ||
| 888 | process.emitData("done\n"); | ||
| 889 | |||
| 890 | yield* manager.close({ threadId: "thread-1" }); | ||
| 891 | |||
| 892 | const reopened = yield* manager.open(openInput()); | ||
| 893 | assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); | ||
| 894 | }), | ||
| 895 | ); | ||
| 896 | |||
| 897 | it.effect( | ||
| 898 | "preserves clear and style control sequences while dropping chunk-split query traffic", | ||
| 899 | () => | ||
| 900 | Effect.gen(function* () { | ||
| 901 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 902 | yield* manager.open(openInput()); | ||
| 903 | const process = ptyAdapter.processes[0]; | ||
| 904 | expect(process).toBeDefined(); | ||
| 905 | if (!process) return; | ||
| 906 | |||
| 907 | process.emitData("before clear\n"); | ||
| 908 | process.emitData("\u001b[H\u001b[2J"); | ||
| 909 | process.emitData("prompt "); | ||
| 910 | process.emitData("\u001b]11;"); | ||
| 911 | process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); | ||
| 912 | process.emitData("R\u001b[36mdone\u001b[0m\n"); | ||
| 913 | |||
| 914 | yield* manager.close({ threadId: "thread-1" }); | ||
| 915 | |||
| 916 | const reopened = yield* manager.open(openInput()); | ||
| 917 | assert.equal( | ||
| 918 | reopened.history, | ||
| 919 | "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", | ||
| 920 | ); | ||
| 921 | }), | ||
| 922 | ); | ||
| 923 | |||
| 924 | it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => | ||
| 925 | Effect.gen(function* () { | ||
| 926 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 927 | yield* manager.open(openInput()); | ||
| 928 | const process = ptyAdapter.processes[0]; | ||
| 929 | expect(process).toBeDefined(); | ||
| 930 | if (!process) return; | ||
| 931 | |||
| 932 | process.emitData("before "); | ||
| 933 | process.emitData("\u001b(B"); | ||
| 934 | process.emitData("after\n"); | ||
| 935 | |||
| 936 | yield* manager.close({ threadId: "thread-1" }); | ||
| 937 | |||
| 938 | const reopened = yield* manager.open(openInput()); | ||
| 939 | assert.equal(reopened.history, "before \u001b(Bafter\n"); | ||
| 940 | }), | ||
| 941 | ); | ||
| 942 | |||
| 943 | it.effect( | ||
| 944 | "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", | ||
| 945 | () => | ||
| 946 | Effect.gen(function* () { | ||
| 947 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 948 | yield* manager.open(openInput()); | ||
| 949 | const process = ptyAdapter.processes[0]; | ||
| 950 | expect(process).toBeDefined(); | ||
| 951 | if (!process) return; | ||
| 952 | |||
| 953 | process.emitData("before "); | ||
| 954 | process.emitData("\u001b("); | ||
| 955 | process.emitData("Bafter\n"); | ||
| 956 | |||
| 957 | yield* manager.close({ threadId: "thread-1" }); | ||
| 958 | |||
| 959 | const reopened = yield* manager.open(openInput()); | ||
| 960 | assert.equal(reopened.history, "before \u001b(Bafter\n"); | ||
| 961 | }), | ||
| 962 | ); | ||
| 963 | |||
| 964 | it.effect("deletes history file when close(deleteHistory=true)", () => | ||
| 965 | Effect.gen(function* () { | ||
| 966 | const { manager, ptyAdapter, logsDir } = yield* createManager(); | ||
| 967 | yield* manager.open(openInput()); | ||
| 968 | const process = ptyAdapter.processes[0]; | ||
| 969 | expect(process).toBeDefined(); | ||
| 970 | if (!process) return; | ||
| 971 | process.emitData("bye\n"); | ||
| 972 | const path = yield* Path.Path; | ||
| 973 | yield* waitFor( | ||
| 974 | historyLogPath(logsDir).pipe( | ||
| 975 | Effect.provideService(Path.Path, path), | ||
| 976 | Effect.flatMap(pathExists), | ||
| 977 | ), | ||
| 978 | ); | ||
| 979 | |||
| 980 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); | ||
| 981 | expect( | ||
| 982 | yield* historyLogPath(logsDir).pipe( | ||
| 983 | Effect.provideService(Path.Path, path), | ||
| 984 | Effect.flatMap(pathExists), | ||
| 985 | ), | ||
| 986 | ).toBe(false); | ||
| 987 | }), | ||
| 988 | ); | ||
| 989 | |||
| 990 | it.effect("closes all terminals for a thread when close omits terminalId", () => | ||
| 991 | Effect.gen(function* () { | ||
| 992 | const { manager, ptyAdapter, logsDir } = yield* createManager(); | ||
| 993 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 994 | yield* manager.open(openInput({ terminalId: "sidecar" })); | ||
| 995 | const defaultProcess = ptyAdapter.processes[0]; | ||
| 996 | const sidecarProcess = ptyAdapter.processes[1]; | ||
| 997 | expect(defaultProcess).toBeDefined(); | ||
| 998 | expect(sidecarProcess).toBeDefined(); | ||
| 999 | if (!defaultProcess || !sidecarProcess) return; | ||
| 1000 | |||
| 1001 | defaultProcess.emitData("default\n"); | ||
| 1002 | sidecarProcess.emitData("sidecar\n"); | ||
| 1003 | const path = yield* Path.Path; | ||
| 1004 | yield* waitFor( | ||
| 1005 | multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( | ||
| 1006 | Effect.provideService(Path.Path, path), | ||
| 1007 | Effect.flatMap(pathExists), | ||
| 1008 | ), | ||
| 1009 | ); | ||
| 1010 | yield* waitFor( | ||
| 1011 | multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( | ||
| 1012 | Effect.provideService(Path.Path, path), | ||
| 1013 | Effect.flatMap(pathExists), | ||
| 1014 | ), | ||
| 1015 | ); | ||
| 1016 | |||
| 1017 | yield* manager.close({ threadId: "thread-1", deleteHistory: true }); | ||
| 1018 | |||
| 1019 | assert.equal(defaultProcess.killed, true); | ||
| 1020 | assert.equal(sidecarProcess.killed, true); | ||
| 1021 | expect( | ||
| 1022 | yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( | ||
| 1023 | Effect.provideService(Path.Path, path), | ||
| 1024 | Effect.flatMap(pathExists), | ||
| 1025 | ), | ||
| 1026 | ).toBe(false); | ||
| 1027 | expect( | ||
| 1028 | yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( | ||
| 1029 | Effect.provideService(Path.Path, path), | ||
| 1030 | Effect.flatMap(pathExists), | ||
| 1031 | ), | ||
| 1032 | ).toBe(false); | ||
| 1033 | }), | ||
| 1034 | ); | ||
| 1035 | |||
| 1036 | it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => | ||
| 1037 | Effect.gen(function* () { | ||
| 1038 | const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); | ||
| 1039 | yield* manager.open(openInput()); | ||
| 1040 | const process = ptyAdapter.processes[0]; | ||
| 1041 | expect(process).toBeDefined(); | ||
| 1042 | if (!process) return; | ||
| 1043 | |||
| 1044 | const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); | ||
| 1045 | yield* Effect.yieldNow; | ||
| 1046 | yield* TestClock.adjust("10 millis"); | ||
| 1047 | yield* Fiber.join(closeFiber); | ||
| 1048 | |||
| 1049 | assert.equal(process.killSignals[0], "SIGTERM"); | ||
| 1050 | expect(process.killSignals).toContain("SIGKILL"); | ||
| 1051 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 1052 | ); | ||
| 1053 | |||
| 1054 | it.effect("publishes closed events when terminals are explicitly closed", () => | ||
| 1055 | Effect.gen(function* () { | ||
| 1056 | const { manager, getEvents } = yield* createManager(); | ||
| 1057 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 1058 | yield* manager.open(openInput({ terminalId: "sidecar" })); | ||
| 1059 | |||
| 1060 | yield* manager.close({ threadId: "thread-1" }); | ||
| 1061 | |||
| 1062 | const closedEvents = (yield* getEvents).filter( | ||
| 1063 | (event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed", | ||
| 1064 | ); | ||
| 1065 | expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); | ||
| 1066 | }), | ||
| 1067 | ); | ||
| 1068 | |||
| 1069 | it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => | ||
| 1070 | Effect.gen(function* () { | ||
| 1071 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { | ||
| 1072 | maxRetainedInactiveSessions: 1, | ||
| 1073 | }); | ||
| 1074 | |||
| 1075 | yield* manager.open(openInput({ threadId: "thread-1" })); | ||
| 1076 | yield* manager.open(openInput({ threadId: "thread-2" })); | ||
| 1077 | |||
| 1078 | const first = ptyAdapter.processes[0]; | ||
| 1079 | const second = ptyAdapter.processes[1]; | ||
| 1080 | expect(first).toBeDefined(); | ||
| 1081 | expect(second).toBeDefined(); | ||
| 1082 | if (!first || !second) return; | ||
| 1083 | |||
| 1084 | first.emitData("first-history\n"); | ||
| 1085 | second.emitData("second-history\n"); | ||
| 1086 | const path = yield* Path.Path; | ||
| 1087 | yield* waitFor( | ||
| 1088 | historyLogPath(logsDir, "thread-1").pipe( | ||
| 1089 | Effect.provideService(Path.Path, path), | ||
| 1090 | Effect.flatMap(pathExists), | ||
| 1091 | ), | ||
| 1092 | ); | ||
| 1093 | first.emitExit({ exitCode: 0, signal: 0 }); | ||
| 1094 | yield* Effect.sleep(Duration.millis(5)); | ||
| 1095 | second.emitExit({ exitCode: 0, signal: 0 }); | ||
| 1096 | |||
| 1097 | yield* waitFor( | ||
| 1098 | Effect.map( | ||
| 1099 | getEvents, | ||
| 1100 | (events) => events.filter((event) => event.type === "exited").length === 2, | ||
| 1101 | ), | ||
| 1102 | ); | ||
| 1103 | |||
| 1104 | const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); | ||
| 1105 | const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); | ||
| 1106 | |||
| 1107 | assert.equal(reopenedFirst.history, "first-history\n"); | ||
| 1108 | assert.equal(reopenedSecond.history, ""); | ||
| 1109 | }), | ||
| 1110 | ); | ||
| 1111 | |||
| 1112 | it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => | ||
| 1113 | Effect.gen(function* () { | ||
| 1114 | const { manager, logsDir } = yield* createManager(); | ||
| 1115 | const path = yield* Path.Path; | ||
| 1116 | const legacyPath = path.join(logsDir, "thread-1.log"); | ||
| 1117 | const nextPath = yield* historyLogPath(logsDir); | ||
| 1118 | yield* writeFileString(legacyPath, "legacy-line\n"); | ||
| 1119 | |||
| 1120 | const snapshot = yield* manager.open(openInput()); | ||
| 1121 | |||
| 1122 | assert.equal(snapshot.history, "legacy-line\n"); | ||
| 1123 | expect(yield* pathExists(nextPath)).toBe(true); | ||
| 1124 | expect(yield* readFileString(nextPath)).toBe("legacy-line\n"); | ||
| 1125 | expect(yield* pathExists(legacyPath)).toBe(false); | ||
| 1126 | }), | ||
| 1127 | ); | ||
| 1128 | |||
| 1129 | it.effect("retries with fallback shells when preferred shell spawn fails", () => | ||
| 1130 | Effect.gen(function* () { | ||
| 1131 | const platform = yield* HostProcessPlatform; | ||
| 1132 | const missingShell = | ||
| 1133 | platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; | ||
| 1134 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1135 | shellResolver: () => missingShell, | ||
| 1136 | }); | ||
| 1137 | ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); | ||
| 1138 | |||
| 1139 | const snapshot = yield* manager.open(openInput()); | ||
| 1140 | |||
| 1141 | assert.equal(snapshot.status, "running"); | ||
| 1142 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); | ||
| 1143 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe( | ||
| 1144 | platform === "win32" ? missingShell : "/definitely/missing-shell", | ||
| 1145 | ); | ||
| 1146 | |||
| 1147 | if (platform === "win32") { | ||
| 1148 | expect( | ||
| 1149 | ptyAdapter.spawnInputs.some( | ||
| 1150 | (input) => | ||
| 1151 | input.shell === "pwsh.exe" || | ||
| 1152 | input.shell === "powershell.exe" || | ||
| 1153 | input.shell === "cmd.exe", | ||
| 1154 | ), | ||
| 1155 | ).toBe(true); | ||
| 1156 | } else { | ||
| 1157 | expect( | ||
| 1158 | ptyAdapter.spawnInputs | ||
| 1159 | .slice(1) | ||
| 1160 | .some((input) => input.shell !== "/definitely/missing-shell"), | ||
| 1161 | ).toBe(true); | ||
| 1162 | } | ||
| 1163 | }), | ||
| 1164 | ); | ||
| 1165 | |||
| 1166 | it.effect("prefers PowerShell over ComSpec for Windows terminals", () => | ||
| 1167 | Effect.gen(function* () { | ||
| 1168 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1169 | env: { | ||
| 1170 | ComSpec: "C:\\Windows\\System32\\cmd.exe", | ||
| 1171 | PATH: "C:\\Windows\\System32", | ||
| 1172 | SystemRoot: "C:\\Windows", | ||
| 1173 | }, | ||
| 1174 | }).pipe(Effect.provide(withHostPlatform("win32"))); | ||
| 1175 | |||
| 1176 | yield* manager.open(openInput()); | ||
| 1177 | |||
| 1178 | expect(ptyAdapter.spawnInputs[0]).toEqual( | ||
| 1179 | expect.objectContaining({ | ||
| 1180 | shell: "pwsh.exe", | ||
| 1181 | args: ["-NoLogo"], | ||
| 1182 | }), | ||
| 1183 | ); | ||
| 1184 | }), | ||
| 1185 | ); | ||
| 1186 | |||
| 1187 | it.effect("falls back to built-in PowerShell by absolute path on Windows", () => | ||
| 1188 | Effect.gen(function* () { | ||
| 1189 | const ptyAdapter = new FakePtyAdapter(); | ||
| 1190 | const { manager } = yield* createManager(5, { | ||
| 1191 | ptyAdapter, | ||
| 1192 | shellResolver: () => "C:\\missing\\custom-shell.exe", | ||
| 1193 | env: { | ||
| 1194 | ComSpec: "C:\\Windows\\System32\\cmd.exe", | ||
| 1195 | PATH: "C:\\Windows\\System32", | ||
| 1196 | SystemRoot: "C:\\Windows", | ||
| 1197 | }, | ||
| 1198 | }).pipe(Effect.provide(withHostPlatform("win32"))); | ||
| 1199 | ptyAdapter.spawnFailures.push( | ||
| 1200 | new Error("spawn custom-shell.exe ENOENT"), | ||
| 1201 | new Error("spawn pwsh.exe ENOENT"), | ||
| 1202 | ); | ||
| 1203 | |||
| 1204 | yield* manager.open(openInput()); | ||
| 1205 | |||
| 1206 | expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ | ||
| 1207 | "C:\\missing\\custom-shell.exe", | ||
| 1208 | "pwsh.exe", | ||
| 1209 | "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", | ||
| 1210 | ]); | ||
| 1211 | expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); | ||
| 1212 | expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); | ||
| 1213 | }), | ||
| 1214 | ); | ||
| 1215 | |||
| 1216 | it.effect("filters app runtime env variables from terminal sessions", () => | ||
| 1217 | Effect.gen(function* () { | ||
| 1218 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1219 | env: { | ||
| 1220 | PORT: "5173", | ||
| 1221 | T3CODE_PORT: "3773", | ||
| 1222 | VITE_DEV_SERVER_URL: "http://localhost:5173", | ||
| 1223 | TEST_TERMINAL_KEEP: "keep-me", | ||
| 1224 | }, | ||
| 1225 | }); | ||
| 1226 | yield* manager.open(openInput()); | ||
| 1227 | const spawnInput = ptyAdapter.spawnInputs[0]; | ||
| 1228 | expect(spawnInput).toBeDefined(); | ||
| 1229 | if (!spawnInput) return; | ||
| 1230 | |||
| 1231 | expect(spawnInput.env.PORT).toBeUndefined(); | ||
| 1232 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | ||
| 1233 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | ||
| 1234 | // Arbitrary host env vars must pass through — terminals inherit the | ||
| 1235 | // user's environment apart from the explicit blocklist. | ||
| 1236 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | ||
| 1237 | }), | ||
| 1238 | ); | ||
| 1239 | |||
| 1240 | it.effect("injects runtime env overrides into spawned terminals", () => | ||
| 1241 | Effect.gen(function* () { | ||
| 1242 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 1243 | yield* manager.open( | ||
| 1244 | openInput({ | ||
| 1245 | env: { | ||
| 1246 | T3CODE_PROJECT_ROOT: "/repo", | ||
| 1247 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | ||
| 1248 | CUSTOM_FLAG: "1", | ||
| 1249 | }, | ||
| 1250 | }), | ||
| 1251 | ); | ||
| 1252 | const spawnInput = ptyAdapter.spawnInputs[0]; | ||
| 1253 | expect(spawnInput).toBeDefined(); | ||
| 1254 | if (!spawnInput) return; | ||
| 1255 | |||
| 1256 | assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); | ||
| 1257 | assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); | ||
| 1258 | assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); | ||
| 1259 | }), | ||
| 1260 | ); | ||
| 1261 | |||
| 1262 | it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => | ||
| 1263 | Effect.gen(function* () { | ||
| 1264 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 1265 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1266 | shellResolver: () => "/bin/zsh", | ||
| 1267 | }); | ||
| 1268 | yield* manager.open(openInput()); | ||
| 1269 | const spawnInput = ptyAdapter.spawnInputs[0]; | ||
| 1270 | expect(spawnInput).toBeDefined(); | ||
| 1271 | if (!spawnInput) return; | ||
| 1272 | |||
| 1273 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | ||
| 1274 | }), | ||
| 1275 | ); | ||
| 1276 | |||
| 1277 | it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => | ||
| 1278 | Effect.gen(function* () { | ||
| 1279 | const { manager, ptyAdapter, getEvents } = yield* createManager(5, { | ||
| 1280 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1281 | }); | ||
| 1282 | |||
| 1283 | yield* manager.open(openInput()); | ||
| 1284 | const process = ptyAdapter.processes[0]; | ||
| 1285 | expect(process).toBeDefined(); | ||
| 1286 | if (!process) return; | ||
| 1287 | |||
| 1288 | process.emitData("hello from callback\n"); | ||
| 1289 | |||
| 1290 | yield* waitFor( | ||
| 1291 | Effect.map(getEvents, (events) => | ||
| 1292 | events.some((event) => event.type === "output" && event.data === "hello from callback\n"), | ||
| 1293 | ), | ||
| 1294 | "1200 millis", | ||
| 1295 | ); | ||
| 1296 | }), | ||
| 1297 | ); | ||
| 1298 | |||
| 1299 | it.effect("pushes PTY callbacks to direct event subscribers", () => | ||
| 1300 | Effect.gen(function* () { | ||
| 1301 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1302 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1303 | }); | ||
| 1304 | const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); | ||
| 1305 | const unsubscribe = yield* manager.subscribe((event) => | ||
| 1306 | Ref.update(subscriberEvents, (events) => [...events, event]), | ||
| 1307 | ); | ||
| 1308 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1309 | |||
| 1310 | yield* manager.open(openInput()); | ||
| 1311 | const process = ptyAdapter.processes[0]; | ||
| 1312 | expect(process).toBeDefined(); | ||
| 1313 | if (!process) return; | ||
| 1314 | |||
| 1315 | process.emitData("hello from subscriber\n"); | ||
| 1316 | |||
| 1317 | yield* waitFor( | ||
| 1318 | Effect.map(Ref.get(subscriberEvents), (events) => | ||
| 1319 | events.some( | ||
| 1320 | (event) => event.type === "output" && event.data === "hello from subscriber\n", | ||
| 1321 | ), | ||
| 1322 | ), | ||
| 1323 | "1200 millis", | ||
| 1324 | ); | ||
| 1325 | }), | ||
| 1326 | ); | ||
| 1327 | |||
| 1328 | it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => | ||
| 1329 | Effect.gen(function* () { | ||
| 1330 | const { manager } = yield* createManager(); | ||
| 1331 | yield* manager.open(openInput({ threadId: "existing-thread" })); | ||
| 1332 | |||
| 1333 | const metadataEvents = yield* Ref.make<ReadonlyArray<TerminalMetadataStreamEvent>>([]); | ||
| 1334 | const unsubscribe = yield* manager.subscribeMetadata((event) => | ||
| 1335 | Ref.update(metadataEvents, (events) => [...events, event]), | ||
| 1336 | ); | ||
| 1337 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1338 | |||
| 1339 | const initialEvents = yield* Ref.get(metadataEvents); | ||
| 1340 | expect(initialEvents[0]).toMatchObject({ | ||
| 1341 | type: "snapshot", | ||
| 1342 | terminals: [ | ||
| 1343 | { | ||
| 1344 | threadId: "existing-thread", | ||
| 1345 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1346 | }, | ||
| 1347 | ], | ||
| 1348 | }); | ||
| 1349 | |||
| 1350 | yield* manager.open(openInput({ threadId: "new-thread" })); | ||
| 1351 | |||
| 1352 | yield* waitFor( | ||
| 1353 | Effect.map(Ref.get(metadataEvents), (events) => | ||
| 1354 | events.some( | ||
| 1355 | (event) => | ||
| 1356 | event.type === "upsert" && | ||
| 1357 | event.terminal.threadId === "new-thread" && | ||
| 1358 | event.terminal.terminalId === DEFAULT_TERMINAL_ID, | ||
| 1359 | ), | ||
| 1360 | ), | ||
| 1361 | "1200 millis", | ||
| 1362 | ); | ||
| 1363 | |||
| 1364 | yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); | ||
| 1365 | |||
| 1366 | yield* waitFor( | ||
| 1367 | Effect.map(Ref.get(metadataEvents), (events) => | ||
| 1368 | events.some( | ||
| 1369 | (event) => | ||
| 1370 | event.type === "remove" && | ||
| 1371 | event.threadId === "new-thread" && | ||
| 1372 | event.terminalId === DEFAULT_TERMINAL_ID, | ||
| 1373 | ), | ||
| 1374 | ), | ||
| 1375 | "1200 millis", | ||
| 1376 | ); | ||
| 1377 | }), | ||
| 1378 | ); | ||
| 1379 | |||
| 1380 | it.effect("removes terminal metadata subscriptions when initial delivery fails", () => | ||
| 1381 | Effect.gen(function* () { | ||
| 1382 | const { manager } = yield* createManager(); | ||
| 1383 | yield* manager.open(openInput({ threadId: "existing-thread" })); | ||
| 1384 | |||
| 1385 | const leakedLiveEvents = yield* Ref.make(0); | ||
| 1386 | const exit = yield* Effect.exit( | ||
| 1387 | manager.subscribeMetadata((event) => | ||
| 1388 | event.type === "snapshot" | ||
| 1389 | ? Effect.die("snapshot listener failed") | ||
| 1390 | : Ref.update(leakedLiveEvents, (count) => count + 1), | ||
| 1391 | ), | ||
| 1392 | ); | ||
| 1393 | |||
| 1394 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 1395 | |||
| 1396 | yield* manager.open(openInput({ threadId: "new-thread" })); | ||
| 1397 | expect(yield* Ref.get(leakedLiveEvents)).toBe(0); | ||
| 1398 | }), | ||
| 1399 | ); | ||
| 1400 | |||
| 1401 | it.effect( | ||
| 1402 | "streams attach snapshots followed by live events without duplicate start snapshots", | ||
| 1403 | () => | ||
| 1404 | Effect.gen(function* () { | ||
| 1405 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1406 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1407 | }); | ||
| 1408 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1409 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 1410 | Ref.update(attachEvents, (events) => [...events, event]), | ||
| 1411 | ); | ||
| 1412 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1413 | |||
| 1414 | const process = ptyAdapter.processes[0]; | ||
| 1415 | expect(process).toBeDefined(); | ||
| 1416 | if (!process) return; | ||
| 1417 | |||
| 1418 | expect(yield* Ref.get(attachEvents)).toMatchObject([ | ||
| 1419 | { | ||
| 1420 | type: "snapshot", | ||
| 1421 | snapshot: { | ||
| 1422 | threadId: "thread-1", | ||
| 1423 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1424 | }, | ||
| 1425 | }, | ||
| 1426 | ]); | ||
| 1427 | |||
| 1428 | process.emitData("hello from attach\n"); | ||
| 1429 | |||
| 1430 | yield* waitFor( | ||
| 1431 | Effect.map(Ref.get(attachEvents), (events) => | ||
| 1432 | events.some((event) => event.type === "output" && event.data === "hello from attach\n"), | ||
| 1433 | ), | ||
| 1434 | "1200 millis", | ||
| 1435 | ); | ||
| 1436 | |||
| 1437 | const events = yield* Ref.get(attachEvents); | ||
| 1438 | expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); | ||
| 1439 | }), | ||
| 1440 | ); | ||
| 1441 | |||
| 1442 | it.effect("buffers attach output delivered during the initial snapshot callback", () => | ||
| 1443 | Effect.gen(function* () { | ||
| 1444 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1445 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1446 | }); | ||
| 1447 | yield* manager.open(openInput()); | ||
| 1448 | |||
| 1449 | const process = ptyAdapter.processes[0]; | ||
| 1450 | expect(process).toBeDefined(); | ||
| 1451 | if (!process) return; | ||
| 1452 | |||
| 1453 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1454 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 1455 | Effect.gen(function* () { | ||
| 1456 | yield* Ref.update(attachEvents, (events) => [...events, event]); | ||
| 1457 | if (event.type === "snapshot") { | ||
| 1458 | yield* Effect.sync(() => process.emitData("during snapshot\n")); | ||
| 1459 | yield* Effect.yieldNow; | ||
| 1460 | } | ||
| 1461 | }), | ||
| 1462 | ); | ||
| 1463 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1464 | |||
| 1465 | yield* waitFor( | ||
| 1466 | Effect.map(Ref.get(attachEvents), (events) => | ||
| 1467 | events.some((event) => event.type === "output" && event.data === "during snapshot\n"), | ||
| 1468 | ), | ||
| 1469 | "1200 millis", | ||
| 1470 | ); | ||
| 1471 | |||
| 1472 | expect(yield* Ref.get(attachEvents)).toMatchObject([ | ||
| 1473 | { type: "snapshot" }, | ||
| 1474 | { type: "output", data: "during snapshot\n" }, | ||
| 1475 | ]); | ||
| 1476 | }), | ||
| 1477 | ); | ||
| 1478 | |||
| 1479 | it.effect("preserves queued PTY output ordering through exit callbacks", () => | ||
| 1480 | Effect.gen(function* () { | ||
| 1481 | const { manager, ptyAdapter, getEvents } = yield* createManager(5, { | ||
| 1482 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1483 | }); | ||
| 1484 | |||
| 1485 | yield* manager.open(openInput()); | ||
| 1486 | const process = ptyAdapter.processes[0]; | ||
| 1487 | expect(process).toBeDefined(); | ||
| 1488 | if (!process) return; | ||
| 1489 | |||
| 1490 | process.emitData("first\n"); | ||
| 1491 | process.emitData("second\n"); | ||
| 1492 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 1493 | |||
| 1494 | yield* waitFor( | ||
| 1495 | Effect.map(getEvents, (events) => { | ||
| 1496 | const relevant = events.filter( | ||
| 1497 | (event) => event.type === "output" || event.type === "exited", | ||
| 1498 | ); | ||
| 1499 | return relevant.length >= 3; | ||
| 1500 | }), | ||
| 1501 | "1200 millis", | ||
| 1502 | ); | ||
| 1503 | |||
| 1504 | const relevant = (yield* getEvents).filter( | ||
| 1505 | (event) => event.type === "output" || event.type === "exited", | ||
| 1506 | ); | ||
| 1507 | expect(relevant).toEqual([ | ||
| 1508 | expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), | ||
| 1509 | expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), | ||
| 1510 | expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), | ||
| 1511 | ]); | ||
| 1512 | |||
| 1513 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1514 | const unsubscribe = yield* manager.attachStream( | ||
| 1515 | { | ||
| 1516 | threadId: "thread-1", | ||
| 1517 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1518 | }, | ||
| 1519 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 1520 | ); | ||
| 1521 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1522 | |||
| 1523 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 1524 | expect(snapshot).toBeDefined(); | ||
| 1525 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 1526 | expect(snapshot.snapshot.sequence).toBe(4); | ||
| 1527 | }), | ||
| 1528 | ); | ||
| 1529 | |||
| 1530 | it.effect("scoped runtime shutdown stops active terminals cleanly", () => | ||
| 1531 | Effect.gen(function* () { | ||
| 1532 | const scope = yield* Scope.make("sequential"); | ||
| 1533 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1534 | processKillGraceMs: 10, | ||
| 1535 | }).pipe(Effect.provideService(Scope.Scope, scope)); | ||
| 1536 | yield* manager.open(openInput()); | ||
| 1537 | const process = ptyAdapter.processes[0]; | ||
| 1538 | expect(process).toBeDefined(); | ||
| 1539 | if (!process) return; | ||
| 1540 | |||
| 1541 | const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); | ||
| 1542 | yield* Effect.yieldNow; | ||
| 1543 | yield* TestClock.adjust("10 millis"); | ||
| 1544 | yield* Fiber.join(closeScope); | ||
| 1545 | |||
| 1546 | assert.equal(process.killSignals[0], "SIGTERM"); | ||
| 1547 | expect(process.killSignals).toContain("SIGKILL"); | ||
| 1548 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 1549 | ); | ||
| 1550 | }); | ||
apps/server/src/terminal/Manager.ts created+2571| ... | @@ -0,0 +1,2571 @@ | ||
| 1 | /** | ||
| 2 | * TerminalManager - Terminal session orchestration service interface. | ||
| 3 | * | ||
| 4 | * Owns terminal lifecycle operations, output fanout, and session state | ||
| 5 | * transitions for thread-scoped terminals. | ||
| 6 | * | ||
| 7 | * @module TerminalManager | ||
| 8 | */ | ||
| 9 | import { | ||
| 10 | DEFAULT_TERMINAL_ID, | ||
| 11 | TerminalCwdError, | ||
| 12 | TerminalError, | ||
| 13 | TerminalHistoryError, | ||
| 14 | TerminalNotRunningError, | ||
| 15 | TerminalSessionLookupError, | ||
| 16 | type TerminalAttachInput, | ||
| 17 | type TerminalAttachStreamEvent, | ||
| 18 | type TerminalClearInput, | ||
| 19 | type TerminalCloseInput, | ||
| 20 | type TerminalEvent, | ||
| 21 | type TerminalMetadataStreamEvent, | ||
| 22 | type TerminalOpenInput, | ||
| 23 | type TerminalResizeInput, | ||
| 24 | type TerminalRestartInput, | ||
| 25 | type TerminalSessionSnapshot, | ||
| 26 | type TerminalSessionStatus, | ||
| 27 | type TerminalSummary, | ||
| 28 | type TerminalWriteInput, | ||
| 29 | } from "@t3tools/contracts"; | ||
| 30 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; | ||
| 31 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 32 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 33 | import * as DateTime from "effect/DateTime"; | ||
| 34 | import * as Context from "effect/Context"; | ||
| 35 | import * as Effect from "effect/Effect"; | ||
| 36 | import * as Encoding from "effect/Encoding"; | ||
| 37 | import * as Equal from "effect/Equal"; | ||
| 38 | import * as Exit from "effect/Exit"; | ||
| 39 | import * as Fiber from "effect/Fiber"; | ||
| 40 | import * as FileSystem from "effect/FileSystem"; | ||
| 41 | import * as Layer from "effect/Layer"; | ||
| 42 | import * as Option from "effect/Option"; | ||
| 43 | import * as Path from "effect/Path"; | ||
| 44 | import * as Schema from "effect/Schema"; | ||
| 45 | import * as Scope from "effect/Scope"; | ||
| 46 | import * as Semaphore from "effect/Semaphore"; | ||
| 47 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 48 | |||
| 49 | import * as ServerConfig from "../config.ts"; | ||
| 50 | import { | ||
| 51 | increment, | ||
| 52 | terminalRestartsTotal, | ||
| 53 | terminalSessionsTotal, | ||
| 54 | } from "../observability/Metrics.ts"; | ||
| 55 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 56 | import * as PortScanner from "../preview/PortScanner.ts"; | ||
| 57 | import * as PtyAdapter from "./PtyAdapter.ts"; | ||
| 58 | |||
| 59 | export { | ||
| 60 | TerminalCwdError, | ||
| 61 | TerminalError, | ||
| 62 | TerminalHistoryError, | ||
| 63 | TerminalNotRunningError, | ||
| 64 | TerminalSessionLookupError, | ||
| 65 | }; | ||
| 66 | |||
| 67 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | ||
| 68 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | ||
| 69 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | ||
| 70 | const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; | ||
| 71 | const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; | ||
| 72 | const DEFAULT_OPEN_COLS = 120; | ||
| 73 | const DEFAULT_OPEN_ROWS = 30; | ||
| 74 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | ||
| 75 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | ||
| 76 | const MAX_TERMINAL_LABEL_LENGTH = 128; | ||
| 77 | |||
| 78 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( | ||
| 79 | "TerminalSubprocessCheckError", | ||
| 80 | { | ||
| 81 | cause: Schema.optional(Schema.Defect()), | ||
| 82 | terminalPid: Schema.Number, | ||
| 83 | command: Schema.Literals(["powershell", "pgrep", "ps"]), | ||
| 84 | }, | ||
| 85 | ) { | ||
| 86 | override get message(): string { | ||
| 87 | return `Failed to inspect terminal subprocesses for PID ${this.terminalPid} with ${this.command}`; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcessSignalError>()( | ||
| 92 | "TerminalProcessSignalError", | ||
| 93 | { | ||
| 94 | cause: Schema.optional(Schema.Defect()), | ||
| 95 | signal: Schema.Literals(["SIGTERM", "SIGKILL"]), | ||
| 96 | terminalPid: Schema.Number, | ||
| 97 | }, | ||
| 98 | ) { | ||
| 99 | override get message(): string { | ||
| 100 | return `Failed to send ${this.signal} to terminal process ${this.terminalPid}`; | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | /** | ||
| 105 | * TerminalManager - Service tag for terminal session orchestration. | ||
| 106 | */ | ||
| 107 | export class TerminalManager extends Context.Service< | ||
| 108 | TerminalManager, | ||
| 109 | { | ||
| 110 | /** | ||
| 111 | * Open or attach to a terminal session. | ||
| 112 | * | ||
| 113 | * Reuses an existing session for the same thread/terminal id and restores | ||
| 114 | * persisted history on first open. | ||
| 115 | */ | ||
| 116 | readonly open: ( | ||
| 117 | input: TerminalOpenInput, | ||
| 118 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | ||
| 119 | |||
| 120 | /** | ||
| 121 | * Attach to a terminal and stream its initial snapshot followed by live events. | ||
| 122 | * | ||
| 123 | * Returns an unsubscribe function. | ||
| 124 | */ | ||
| 125 | readonly attachStream: ( | ||
| 126 | input: TerminalAttachInput, | ||
| 127 | listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>, | ||
| 128 | ) => Effect.Effect<() => void, TerminalError>; | ||
| 129 | |||
| 130 | /** | ||
| 131 | * Write input bytes to a terminal session. | ||
| 132 | */ | ||
| 133 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>; | ||
| 134 | |||
| 135 | /** | ||
| 136 | * Resize the PTY backing a terminal session. | ||
| 137 | */ | ||
| 138 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>; | ||
| 139 | |||
| 140 | /** | ||
| 141 | * Clear terminal output history. | ||
| 142 | */ | ||
| 143 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>; | ||
| 144 | |||
| 145 | /** | ||
| 146 | * Restart a terminal session in place. | ||
| 147 | * | ||
| 148 | * Always resets history before spawning the new process. | ||
| 149 | */ | ||
| 150 | readonly restart: ( | ||
| 151 | input: TerminalRestartInput, | ||
| 152 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | ||
| 153 | |||
| 154 | /** | ||
| 155 | * Close an active terminal session. | ||
| 156 | * | ||
| 157 | * When `terminalId` is omitted, closes all sessions for the thread. | ||
| 158 | */ | ||
| 159 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>; | ||
| 160 | |||
| 161 | /** | ||
| 162 | * Subscribe to terminal runtime events with a direct callback. | ||
| 163 | * | ||
| 164 | * Returns an unsubscribe function. | ||
| 165 | */ | ||
| 166 | readonly subscribe: ( | ||
| 167 | listener: (event: TerminalEvent) => Effect.Effect<void>, | ||
| 168 | ) => Effect.Effect<() => void>; | ||
| 169 | |||
| 170 | /** | ||
| 171 | * Subscribe to lightweight terminal metadata with an initial full snapshot. | ||
| 172 | * | ||
| 173 | * Returns an unsubscribe function. | ||
| 174 | */ | ||
| 175 | readonly subscribeMetadata: ( | ||
| 176 | listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>, | ||
| 177 | ) => Effect.Effect<() => void>; | ||
| 178 | } | ||
| 179 | >()("t3/terminal/Manager/TerminalManager") {} | ||
| 180 | |||
| 181 | interface TerminalSubprocessInspectResult { | ||
| 182 | readonly hasRunningSubprocess: boolean; | ||
| 183 | readonly childCommand: string | null; | ||
| 184 | readonly processIds: ReadonlyArray<number>; | ||
| 185 | } | ||
| 186 | |||
| 187 | interface TerminalSubprocessInspector { | ||
| 188 | ( | ||
| 189 | terminalPid: number, | ||
| 190 | ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>; | ||
| 191 | } | ||
| 192 | |||
| 193 | export interface ShellCandidate { | ||
| 194 | shell: string; | ||
| 195 | args?: string[]; | ||
| 196 | } | ||
| 197 | |||
| 198 | export interface TerminalStartInput extends TerminalOpenInput { | ||
| 199 | cols: number; | ||
| 200 | rows: number; | ||
| 201 | } | ||
| 202 | |||
| 203 | export interface TerminalSessionState { | ||
| 204 | threadId: string; | ||
| 205 | terminalId: string; | ||
| 206 | cwd: string; | ||
| 207 | worktreePath: string | null; | ||
| 208 | status: TerminalSessionStatus; | ||
| 209 | pid: number | null; | ||
| 210 | history: string; | ||
| 211 | pendingHistoryControlSequence: string; | ||
| 212 | pendingProcessEvents: Array<PendingProcessEvent>; | ||
| 213 | pendingProcessEventIndex: number; | ||
| 214 | processEventDrainRunning: boolean; | ||
| 215 | exitCode: number | null; | ||
| 216 | exitSignal: number | null; | ||
| 217 | updatedAt: string; | ||
| 218 | eventSequence: number; | ||
| 219 | cols: number; | ||
| 220 | rows: number; | ||
| 221 | process: PtyAdapter.PtyProcess | null; | ||
| 222 | unsubscribeData: (() => void) | null; | ||
| 223 | unsubscribeExit: (() => void) | null; | ||
| 224 | hasRunningSubprocess: boolean; | ||
| 225 | /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ | ||
| 226 | childCommandLabel: string | null; | ||
| 227 | runtimeEnv: Record<string, string> | null; | ||
| 228 | } | ||
| 229 | |||
| 230 | interface PersistHistoryRequest { | ||
| 231 | history: string; | ||
| 232 | immediate: boolean; | ||
| 233 | } | ||
| 234 | |||
| 235 | type PendingProcessEvent = | ||
| 236 | | { type: "output"; data: string } | ||
| 237 | | { type: "exit"; event: PtyAdapter.PtyExitEvent }; | ||
| 238 | |||
| 239 | type DrainProcessEventAction = | ||
| 240 | | { type: "idle" } | ||
| 241 | | { | ||
| 242 | type: "output"; | ||
| 243 | threadId: string; | ||
| 244 | terminalId: string; | ||
| 245 | sequence: number; | ||
| 246 | history: string | null; | ||
| 247 | data: string; | ||
| 248 | } | ||
| 249 | | { | ||
| 250 | type: "exit"; | ||
| 251 | process: PtyAdapter.PtyProcess | null; | ||
| 252 | threadId: string; | ||
| 253 | terminalId: string; | ||
| 254 | sequence: number; | ||
| 255 | exitCode: number | null; | ||
| 256 | exitSignal: number | null; | ||
| 257 | }; | ||
| 258 | |||
| 259 | interface TerminalManagerState { | ||
| 260 | sessions: Map<string, TerminalSessionState>; | ||
| 261 | killFibers: Map<PtyAdapter.PtyProcess, Fiber.Fiber<void, never>>; | ||
| 262 | } | ||
| 263 | |||
| 264 | function truncateTerminalWireLabel(value: string): string { | ||
| 265 | if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; | ||
| 266 | return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); | ||
| 267 | } | ||
| 268 | |||
| 269 | function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { | ||
| 270 | let trimmed = raw.trim(); | ||
| 271 | if (trimmed.length === 0) return null; | ||
| 272 | if ( | ||
| 273 | (trimmed.startsWith("[") && trimmed.endsWith("]")) || | ||
| 274 | (trimmed.startsWith("(") && trimmed.endsWith(")")) | ||
| 275 | ) { | ||
| 276 | trimmed = trimmed.slice(1, -1).trim(); | ||
| 277 | } | ||
| 278 | const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); | ||
| 279 | if (firstToken.length === 0) return null; | ||
| 280 | const separators = platform === "win32" ? /[\\/]/ : /\//; | ||
| 281 | const base = firstToken.split(separators).at(-1) ?? firstToken; | ||
| 282 | const withoutExe = | ||
| 283 | platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; | ||
| 284 | return withoutExe.length > 0 ? withoutExe : null; | ||
| 285 | } | ||
| 286 | |||
| 287 | function terminalWireLabel(session: TerminalSessionState): string { | ||
| 288 | if (session.hasRunningSubprocess && session.childCommandLabel) { | ||
| 289 | const trimmed = session.childCommandLabel.trim(); | ||
| 290 | if (trimmed.length > 0) { | ||
| 291 | return truncateTerminalWireLabel(trimmed); | ||
| 292 | } | ||
| 293 | } | ||
| 294 | return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); | ||
| 295 | } | ||
| 296 | |||
| 297 | function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | ||
| 298 | return { | ||
| 299 | threadId: session.threadId, | ||
| 300 | terminalId: session.terminalId, | ||
| 301 | cwd: session.cwd, | ||
| 302 | worktreePath: session.worktreePath, | ||
| 303 | status: session.status, | ||
| 304 | pid: session.pid, | ||
| 305 | history: session.history, | ||
| 306 | exitCode: session.exitCode, | ||
| 307 | exitSignal: session.exitSignal, | ||
| 308 | label: terminalWireLabel(session), | ||
| 309 | updatedAt: session.updatedAt, | ||
| 310 | sequence: session.eventSequence, | ||
| 311 | }; | ||
| 312 | } | ||
| 313 | |||
| 314 | function summary(session: TerminalSessionState): TerminalSummary { | ||
| 315 | return { | ||
| 316 | threadId: session.threadId, | ||
| 317 | terminalId: session.terminalId, | ||
| 318 | cwd: session.cwd, | ||
| 319 | worktreePath: session.worktreePath, | ||
| 320 | status: session.status, | ||
| 321 | pid: session.pid, | ||
| 322 | exitCode: session.exitCode, | ||
| 323 | exitSignal: session.exitSignal, | ||
| 324 | hasRunningSubprocess: session.hasRunningSubprocess, | ||
| 325 | label: terminalWireLabel(session), | ||
| 326 | updatedAt: session.updatedAt, | ||
| 327 | }; | ||
| 328 | } | ||
| 329 | |||
| 330 | function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { | ||
| 331 | switch (event.type) { | ||
| 332 | case "started": | ||
| 333 | case "restarted": | ||
| 334 | case "exited": | ||
| 335 | case "closed": | ||
| 336 | case "error": | ||
| 337 | case "activity": | ||
| 338 | return true; | ||
| 339 | case "output": | ||
| 340 | case "cleared": | ||
| 341 | return false; | ||
| 342 | } | ||
| 343 | } | ||
| 344 | |||
| 345 | function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { | ||
| 346 | switch (event.type) { | ||
| 347 | case "started": | ||
| 348 | return { | ||
| 349 | type: "snapshot", | ||
| 350 | snapshot: event.snapshot, | ||
| 351 | }; | ||
| 352 | case "output": | ||
| 353 | case "exited": | ||
| 354 | case "closed": | ||
| 355 | case "error": | ||
| 356 | case "cleared": | ||
| 357 | case "restarted": | ||
| 358 | case "activity": | ||
| 359 | return event; | ||
| 360 | } | ||
| 361 | } | ||
| 362 | |||
| 363 | function isDuplicateAttachSnapshotEvent( | ||
| 364 | event: TerminalEvent, | ||
| 365 | initialSnapshot: TerminalSessionSnapshot, | ||
| 366 | ) { | ||
| 367 | return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" | ||
| 368 | ? event.sequence <= initialSnapshot.sequence | ||
| 369 | : event.type === "started" && | ||
| 370 | event.snapshot.threadId === initialSnapshot.threadId && | ||
| 371 | event.snapshot.terminalId === initialSnapshot.terminalId && | ||
| 372 | event.snapshot.updatedAt <= initialSnapshot.updatedAt; | ||
| 373 | } | ||
| 374 | |||
| 375 | function advanceEventSequence(session: TerminalSessionState): { | ||
| 376 | readonly updatedAt: string; | ||
| 377 | readonly sequence: number; | ||
| 378 | } { | ||
| 379 | const updatedAt = DateTime.formatIso(DateTime.nowUnsafe()); | ||
| 380 | session.eventSequence += 1; | ||
| 381 | session.updatedAt = updatedAt; | ||
| 382 | return { updatedAt, sequence: session.eventSequence }; | ||
| 383 | } | ||
| 384 | |||
| 385 | function cleanupProcessHandles(session: TerminalSessionState): void { | ||
| 386 | session.unsubscribeData?.(); | ||
| 387 | session.unsubscribeData = null; | ||
| 388 | session.unsubscribeExit?.(); | ||
| 389 | session.unsubscribeExit = null; | ||
| 390 | } | ||
| 391 | |||
| 392 | function enqueueProcessEvent( | ||
| 393 | session: TerminalSessionState, | ||
| 394 | expectedPid: number, | ||
| 395 | event: PendingProcessEvent, | ||
| 396 | ): boolean { | ||
| 397 | if (!session.process || session.status !== "running" || session.pid !== expectedPid) { | ||
| 398 | return false; | ||
| 399 | } | ||
| 400 | |||
| 401 | session.pendingProcessEvents.push(event); | ||
| 402 | if (session.processEventDrainRunning) { | ||
| 403 | return false; | ||
| 404 | } | ||
| 405 | |||
| 406 | session.processEventDrainRunning = true; | ||
| 407 | return true; | ||
| 408 | } | ||
| 409 | |||
| 410 | function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { | ||
| 411 | if (platform === "win32") { | ||
| 412 | return "pwsh.exe"; | ||
| 413 | } | ||
| 414 | return env.SHELL ?? "bash"; | ||
| 415 | } | ||
| 416 | |||
| 417 | function normalizeShellCommand( | ||
| 418 | value: string | undefined, | ||
| 419 | platform: NodeJS.Platform, | ||
| 420 | ): string | null { | ||
| 421 | if (!value) return null; | ||
| 422 | const trimmed = value.trim(); | ||
| 423 | if (trimmed.length === 0) return null; | ||
| 424 | |||
| 425 | if (platform === "win32") { | ||
| 426 | return trimmed; | ||
| 427 | } | ||
| 428 | |||
| 429 | const firstToken = trimmed.split(/\s+/g)[0]?.trim(); | ||
| 430 | if (!firstToken) return null; | ||
| 431 | return firstToken.replace(/^['"]|['"]$/g, ""); | ||
| 432 | } | ||
| 433 | |||
| 434 | function basenameForPlatform(command: string, platform: NodeJS.Platform): string { | ||
| 435 | const normalized = | ||
| 436 | platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); | ||
| 437 | const parts = normalized | ||
| 438 | .split(platform === "win32" ? /\\+/ : /\/+/) | ||
| 439 | .filter((part) => part.length > 0); | ||
| 440 | return parts.at(-1) ?? normalized; | ||
| 441 | } | ||
| 442 | |||
| 443 | function joinWindowsPath(...parts: ReadonlyArray<string>): string { | ||
| 444 | return parts | ||
| 445 | .map((part, index) => { | ||
| 446 | if (index === 0) return part.replace(/[\\/]+$/g, ""); | ||
| 447 | return part.replace(/^[\\/]+|[\\/]+$/g, ""); | ||
| 448 | }) | ||
| 449 | .filter((part) => part.length > 0) | ||
| 450 | .join("\\"); | ||
| 451 | } | ||
| 452 | |||
| 453 | function shellCandidateFromCommand( | ||
| 454 | command: string | null, | ||
| 455 | platform: NodeJS.Platform, | ||
| 456 | ): ShellCandidate | null { | ||
| 457 | if (!command || command.length === 0) return null; | ||
| 458 | const shellName = basenameForPlatform(command, platform).toLowerCase(); | ||
| 459 | if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { | ||
| 460 | return { shell: command, args: ["-NoLogo"] }; | ||
| 461 | } | ||
| 462 | if (platform !== "win32" && shellName === "zsh") { | ||
| 463 | return { shell: command, args: ["-o", "nopromptsp"] }; | ||
| 464 | } | ||
| 465 | return { shell: command }; | ||
| 466 | } | ||
| 467 | |||
| 468 | function windowsSystemRoot(env: NodeJS.ProcessEnv): string { | ||
| 469 | return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; | ||
| 470 | } | ||
| 471 | |||
| 472 | function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { | ||
| 473 | return joinWindowsPath( | ||
| 474 | windowsSystemRoot(env), | ||
| 475 | "System32", | ||
| 476 | "WindowsPowerShell", | ||
| 477 | "v1.0", | ||
| 478 | "powershell.exe", | ||
| 479 | ); | ||
| 480 | } | ||
| 481 | |||
| 482 | function windowsCmdPath(env: NodeJS.ProcessEnv): string { | ||
| 483 | return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); | ||
| 484 | } | ||
| 485 | |||
| 486 | function formatShellCandidate(candidate: ShellCandidate): string { | ||
| 487 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | ||
| 488 | return `${candidate.shell} ${candidate.args.join(" ")}`; | ||
| 489 | } | ||
| 490 | |||
| 491 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | ||
| 492 | const seen = new Set<string>(); | ||
| 493 | const ordered: ShellCandidate[] = []; | ||
| 494 | for (const candidate of candidates) { | ||
| 495 | if (!candidate) continue; | ||
| 496 | const key = formatShellCandidate(candidate); | ||
| 497 | if (seen.has(key)) continue; | ||
| 498 | seen.add(key); | ||
| 499 | ordered.push(candidate); | ||
| 500 | } | ||
| 501 | return ordered; | ||
| 502 | } | ||
| 503 | |||
| 504 | function resolveShellCandidates( | ||
| 505 | shellResolver: () => string, | ||
| 506 | platform: NodeJS.Platform, | ||
| 507 | env: NodeJS.ProcessEnv, | ||
| 508 | ): ShellCandidate[] { | ||
| 509 | const requested = shellCandidateFromCommand( | ||
| 510 | normalizeShellCommand(shellResolver(), platform), | ||
| 511 | platform, | ||
| 512 | ); | ||
| 513 | |||
| 514 | if (platform === "win32") { | ||
| 515 | return uniqueShellCandidates([ | ||
| 516 | requested, | ||
| 517 | shellCandidateFromCommand("pwsh.exe", platform), | ||
| 518 | shellCandidateFromCommand(windowsPowerShellPath(env), platform), | ||
| 519 | shellCandidateFromCommand("powershell.exe", platform), | ||
| 520 | shellCandidateFromCommand(env.ComSpec ?? null, platform), | ||
| 521 | shellCandidateFromCommand(windowsCmdPath(env), platform), | ||
| 522 | shellCandidateFromCommand("cmd.exe", platform), | ||
| 523 | ]); | ||
| 524 | } | ||
| 525 | |||
| 526 | return uniqueShellCandidates([ | ||
| 527 | requested, | ||
| 528 | shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), | ||
| 529 | shellCandidateFromCommand("/bin/zsh", platform), | ||
| 530 | shellCandidateFromCommand("/bin/bash", platform), | ||
| 531 | shellCandidateFromCommand("/bin/sh", platform), | ||
| 532 | shellCandidateFromCommand("zsh", platform), | ||
| 533 | shellCandidateFromCommand("bash", platform), | ||
| 534 | shellCandidateFromCommand("sh", platform), | ||
| 535 | ]); | ||
| 536 | } | ||
| 537 | |||
| 538 | function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { | ||
| 539 | const queue: unknown[] = [error]; | ||
| 540 | const seen = new Set<unknown>(); | ||
| 541 | const messages: string[] = []; | ||
| 542 | |||
| 543 | while (queue.length > 0) { | ||
| 544 | const current = queue.shift(); | ||
| 545 | if (!current || seen.has(current)) { | ||
| 546 | continue; | ||
| 547 | } | ||
| 548 | seen.add(current); | ||
| 549 | |||
| 550 | if (typeof current === "string") { | ||
| 551 | messages.push(current); | ||
| 552 | continue; | ||
| 553 | } | ||
| 554 | |||
| 555 | if (current instanceof Error) { | ||
| 556 | messages.push(current.message); | ||
| 557 | if (current.cause) { | ||
| 558 | queue.push(current.cause); | ||
| 559 | } | ||
| 560 | continue; | ||
| 561 | } | ||
| 562 | |||
| 563 | if (typeof current === "object") { | ||
| 564 | const value = current as { message?: unknown; cause?: unknown }; | ||
| 565 | if (typeof value.message === "string") { | ||
| 566 | messages.push(value.message); | ||
| 567 | } | ||
| 568 | if (value.cause) { | ||
| 569 | queue.push(value.cause); | ||
| 570 | } | ||
| 571 | } | ||
| 572 | } | ||
| 573 | |||
| 574 | const message = messages.join(" ").toLowerCase(); | ||
| 575 | return ( | ||
| 576 | message.includes("posix_spawnp failed") || | ||
| 577 | message.includes("enoent") || | ||
| 578 | message.includes("not found") || | ||
| 579 | message.includes("file not found") || | ||
| 580 | message.includes("no such file") | ||
| 581 | ); | ||
| 582 | } | ||
| 583 | |||
| 584 | function parseFirstChildPidFromPgrep(stdout: string): number | null { | ||
| 585 | for (const line of stdout.split(/\r?\n/g)) { | ||
| 586 | const n = Number.parseInt(line.trim(), 10); | ||
| 587 | if (Number.isInteger(n) && n > 0) { | ||
| 588 | return n; | ||
| 589 | } | ||
| 590 | } | ||
| 591 | return null; | ||
| 592 | } | ||
| 593 | |||
| 594 | function windowsInspectSubprocess( | ||
| 595 | terminalPid: number, | ||
| 596 | platform: NodeJS.Platform, | ||
| 597 | ): Effect.Effect< | ||
| 598 | TerminalSubprocessInspectResult, | ||
| 599 | TerminalSubprocessCheckError, | ||
| 600 | ProcessRunner.ProcessRunner | ||
| 601 | > { | ||
| 602 | const command = | ||
| 603 | 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; | ||
| 604 | return Effect.gen(function* () { | ||
| 605 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 606 | return yield* processRunner.run({ | ||
| 607 | // powershell.exe is a real executable — never spawn it through cmd.exe | ||
| 608 | // shell mode, which would re-tokenize the `-Command` payload (pipes, | ||
| 609 | // semicolons) before PowerShell ever sees it. | ||
| 610 | command: "powershell.exe", | ||
| 611 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | ||
| 612 | timeout: "1500 millis", | ||
| 613 | maxOutputBytes: 32_768, | ||
| 614 | outputMode: "truncate", | ||
| 615 | timeoutBehavior: "timedOutResult", | ||
| 616 | }); | ||
| 617 | }).pipe( | ||
| 618 | Effect.map((result) => { | ||
| 619 | if (result.code !== 0) { | ||
| 620 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; | ||
| 621 | } | ||
| 622 | const processNameById = new Map<number, string>(); | ||
| 623 | const childrenByParent = new Map<number, number[]>(); | ||
| 624 | for (const line of result.stdout.split(/\r?\n/g)) { | ||
| 625 | const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); | ||
| 626 | const pid = Number(pidRaw); | ||
| 627 | const parentPid = Number(parentPidRaw); | ||
| 628 | if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; | ||
| 629 | processNameById.set(pid, nameRaw?.trim() ?? ""); | ||
| 630 | const children = childrenByParent.get(parentPid) ?? []; | ||
| 631 | children.push(pid); | ||
| 632 | childrenByParent.set(parentPid, children); | ||
| 633 | } | ||
| 634 | const directChildren = childrenByParent.get(terminalPid) ?? []; | ||
| 635 | const childPid = directChildren[0]; | ||
| 636 | if (childPid === undefined) { | ||
| 637 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; | ||
| 638 | } | ||
| 639 | const processIds = new Set<number>([terminalPid]); | ||
| 640 | const pending = [terminalPid]; | ||
| 641 | while (pending.length > 0) { | ||
| 642 | const parentPid = pending.pop(); | ||
| 643 | if (parentPid === undefined) continue; | ||
| 644 | for (const pid of childrenByParent.get(parentPid) ?? []) { | ||
| 645 | if (processIds.has(pid)) continue; | ||
| 646 | processIds.add(pid); | ||
| 647 | pending.push(pid); | ||
| 648 | } | ||
| 649 | } | ||
| 650 | const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); | ||
| 651 | return { | ||
| 652 | hasRunningSubprocess: true, | ||
| 653 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 654 | processIds: [...processIds], | ||
| 655 | } as const; | ||
| 656 | }), | ||
| 657 | Effect.mapError( | ||
| 658 | (cause) => | ||
| 659 | new TerminalSubprocessCheckError({ | ||
| 660 | cause, | ||
| 661 | terminalPid, | ||
| 662 | command: "powershell", | ||
| 663 | }), | ||
| 664 | ), | ||
| 665 | ); | ||
| 666 | } | ||
| 667 | |||
| 668 | const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( | ||
| 669 | terminalPid: number, | ||
| 670 | platform: NodeJS.Platform, | ||
| 671 | ): Effect.fn.Return< | ||
| 672 | TerminalSubprocessInspectResult, | ||
| 673 | TerminalSubprocessCheckError, | ||
| 674 | ProcessRunner.ProcessRunner | ||
| 675 | > { | ||
| 676 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 677 | const runPgrep = processRunner | ||
| 678 | .run({ | ||
| 679 | command: "pgrep", | ||
| 680 | args: ["-P", String(terminalPid)], | ||
| 681 | timeout: "1 second", | ||
| 682 | maxOutputBytes: 32_768, | ||
| 683 | outputMode: "truncate", | ||
| 684 | timeoutBehavior: "timedOutResult", | ||
| 685 | }) | ||
| 686 | .pipe( | ||
| 687 | Effect.mapError( | ||
| 688 | (cause) => | ||
| 689 | new TerminalSubprocessCheckError({ | ||
| 690 | cause, | ||
| 691 | terminalPid, | ||
| 692 | command: "pgrep", | ||
| 693 | }), | ||
| 694 | ), | ||
| 695 | ); | ||
| 696 | |||
| 697 | const runPs = processRunner | ||
| 698 | .run({ | ||
| 699 | command: "ps", | ||
| 700 | args: ["-eo", "pid=,ppid="], | ||
| 701 | timeout: "1 second", | ||
| 702 | maxOutputBytes: 262_144, | ||
| 703 | outputMode: "truncate", | ||
| 704 | timeoutBehavior: "timedOutResult", | ||
| 705 | }) | ||
| 706 | .pipe( | ||
| 707 | Effect.mapError( | ||
| 708 | (cause) => | ||
| 709 | new TerminalSubprocessCheckError({ | ||
| 710 | cause, | ||
| 711 | terminalPid, | ||
| 712 | command: "ps", | ||
| 713 | }), | ||
| 714 | ), | ||
| 715 | ); | ||
| 716 | |||
| 717 | let childPid: number | null = null; | ||
| 718 | |||
| 719 | const pgrepResult = yield* Effect.exit(runPgrep); | ||
| 720 | if (pgrepResult._tag === "Success") { | ||
| 721 | if (pgrepResult.value.code === 0) { | ||
| 722 | childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); | ||
| 723 | } else if (pgrepResult.value.code === 1) { | ||
| 724 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 725 | } | ||
| 726 | } | ||
| 727 | |||
| 728 | if (childPid === null) { | ||
| 729 | const psResult = yield* Effect.exit(runPs); | ||
| 730 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { | ||
| 731 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 732 | } | ||
| 733 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | ||
| 734 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | ||
| 735 | const pid = Number(pidRaw); | ||
| 736 | const ppid = Number(ppidRaw); | ||
| 737 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 738 | if (ppid === terminalPid) { | ||
| 739 | childPid = pid; | ||
| 740 | break; | ||
| 741 | } | ||
| 742 | } | ||
| 743 | } | ||
| 744 | |||
| 745 | if (childPid === null) { | ||
| 746 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 747 | } | ||
| 748 | |||
| 749 | const runComm = processRunner.run({ | ||
| 750 | command: "ps", | ||
| 751 | args: ["-p", String(childPid), "-o", "comm="], | ||
| 752 | timeout: "1 second", | ||
| 753 | maxOutputBytes: 8_192, | ||
| 754 | outputMode: "truncate", | ||
| 755 | timeoutBehavior: "timedOutResult", | ||
| 756 | }); | ||
| 757 | |||
| 758 | const commResult = yield* Effect.exit(runComm); | ||
| 759 | let rawComm: string | null = null; | ||
| 760 | if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { | ||
| 761 | rawComm = commResult.value.stdout.trim(); | ||
| 762 | } | ||
| 763 | |||
| 764 | if (!rawComm || rawComm.length === 0) { | ||
| 765 | const runArgs = processRunner.run({ | ||
| 766 | command: "ps", | ||
| 767 | args: ["-p", String(childPid), "-o", "args="], | ||
| 768 | timeout: "1 second", | ||
| 769 | maxOutputBytes: 16_384, | ||
| 770 | outputMode: "truncate", | ||
| 771 | timeoutBehavior: "timedOutResult", | ||
| 772 | }); | ||
| 773 | const argsResult = yield* Effect.exit(runArgs); | ||
| 774 | if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { | ||
| 775 | const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; | ||
| 776 | rawComm = first.length > 0 ? first : null; | ||
| 777 | } | ||
| 778 | } | ||
| 779 | |||
| 780 | const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; | ||
| 781 | const processIds = new Set<number>([terminalPid]); | ||
| 782 | const psResult = yield* Effect.exit(runPs); | ||
| 783 | if (psResult._tag === "Success" && psResult.value.code === 0) { | ||
| 784 | const childrenByParent = new Map<number, number[]>(); | ||
| 785 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | ||
| 786 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | ||
| 787 | const pid = Number(pidRaw); | ||
| 788 | const ppid = Number(ppidRaw); | ||
| 789 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 790 | const children = childrenByParent.get(ppid) ?? []; | ||
| 791 | children.push(pid); | ||
| 792 | childrenByParent.set(ppid, children); | ||
| 793 | } | ||
| 794 | const pending = [terminalPid]; | ||
| 795 | while (pending.length > 0) { | ||
| 796 | const parentPid = pending.pop(); | ||
| 797 | if (parentPid === undefined) continue; | ||
| 798 | for (const child of childrenByParent.get(parentPid) ?? []) { | ||
| 799 | if (processIds.has(child)) continue; | ||
| 800 | processIds.add(child); | ||
| 801 | pending.push(child); | ||
| 802 | } | ||
| 803 | } | ||
| 804 | } else { | ||
| 805 | processIds.add(childPid); | ||
| 806 | } | ||
| 807 | return { | ||
| 808 | hasRunningSubprocess: true, | ||
| 809 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 810 | processIds: [...processIds], | ||
| 811 | }; | ||
| 812 | }); | ||
| 813 | |||
| 814 | function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { | ||
| 815 | return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { | ||
| 816 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | ||
| 817 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 818 | } | ||
| 819 | if (platform === "win32") { | ||
| 820 | return yield* windowsInspectSubprocess(terminalPid, platform); | ||
| 821 | } | ||
| 822 | return yield* posixInspectSubprocess(terminalPid, platform); | ||
| 823 | }); | ||
| 824 | } | ||
| 825 | |||
| 826 | function capHistory(history: string, maxLines: number): string { | ||
| 827 | if (history.length === 0) return history; | ||
| 828 | const hasTrailingNewline = history.endsWith("\n"); | ||
| 829 | const lines = history.split("\n"); | ||
| 830 | if (hasTrailingNewline) { | ||
| 831 | lines.pop(); | ||
| 832 | } | ||
| 833 | if (lines.length <= maxLines) return history; | ||
| 834 | const capped = lines.slice(lines.length - maxLines).join("\n"); | ||
| 835 | return hasTrailingNewline ? `${capped}\n` : capped; | ||
| 836 | } | ||
| 837 | |||
| 838 | function isCsiFinalByte(codePoint: number): boolean { | ||
| 839 | return codePoint >= 0x40 && codePoint <= 0x7e; | ||
| 840 | } | ||
| 841 | |||
| 842 | function shouldStripCsiSequence(body: string, finalByte: string): boolean { | ||
| 843 | if (finalByte === "n") { | ||
| 844 | return true; | ||
| 845 | } | ||
| 846 | if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { | ||
| 847 | return true; | ||
| 848 | } | ||
| 849 | if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { | ||
| 850 | return true; | ||
| 851 | } | ||
| 852 | return false; | ||
| 853 | } | ||
| 854 | |||
| 855 | function shouldStripOscSequence(content: string): boolean { | ||
| 856 | return /^(10|11|12);(?:\?|rgb:)/.test(content); | ||
| 857 | } | ||
| 858 | |||
| 859 | function stripStringTerminator(value: string): string { | ||
| 860 | if (value.endsWith("\u001b\\")) { | ||
| 861 | return value.slice(0, -2); | ||
| 862 | } | ||
| 863 | const lastCharacter = value.at(-1); | ||
| 864 | if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { | ||
| 865 | return value.slice(0, -1); | ||
| 866 | } | ||
| 867 | return value; | ||
| 868 | } | ||
| 869 | |||
| 870 | function findStringTerminatorIndex(input: string, start: number): number | null { | ||
| 871 | for (let index = start; index < input.length; index += 1) { | ||
| 872 | const codePoint = input.charCodeAt(index); | ||
| 873 | if (codePoint === 0x07 || codePoint === 0x9c) { | ||
| 874 | return index + 1; | ||
| 875 | } | ||
| 876 | if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { | ||
| 877 | return index + 2; | ||
| 878 | } | ||
| 879 | } | ||
| 880 | return null; | ||
| 881 | } | ||
| 882 | |||
| 883 | function isEscapeIntermediateByte(codePoint: number): boolean { | ||
| 884 | return codePoint >= 0x20 && codePoint <= 0x2f; | ||
| 885 | } | ||
| 886 | |||
| 887 | function isEscapeFinalByte(codePoint: number): boolean { | ||
| 888 | return codePoint >= 0x30 && codePoint <= 0x7e; | ||
| 889 | } | ||
| 890 | |||
| 891 | function findEscapeSequenceEndIndex(input: string, start: number): number | null { | ||
| 892 | let cursor = start; | ||
| 893 | while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { | ||
| 894 | cursor += 1; | ||
| 895 | } | ||
| 896 | if (cursor >= input.length) { | ||
| 897 | return null; | ||
| 898 | } | ||
| 899 | return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; | ||
| 900 | } | ||
| 901 | |||
| 902 | function sanitizeTerminalHistoryChunk( | ||
| 903 | pendingControlSequence: string, | ||
| 904 | data: string, | ||
| 905 | ): { visibleText: string; pendingControlSequence: string } { | ||
| 906 | const input = `${pendingControlSequence}${data}`; | ||
| 907 | let visibleText = ""; | ||
| 908 | let index = 0; | ||
| 909 | |||
| 910 | const append = (value: string) => { | ||
| 911 | visibleText += value; | ||
| 912 | }; | ||
| 913 | |||
| 914 | while (index < input.length) { | ||
| 915 | const codePoint = input.charCodeAt(index); | ||
| 916 | |||
| 917 | if (codePoint === 0x1b) { | ||
| 918 | const nextCodePoint = input.charCodeAt(index + 1); | ||
| 919 | if (Number.isNaN(nextCodePoint)) { | ||
| 920 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 921 | } | ||
| 922 | |||
| 923 | if (nextCodePoint === 0x5b) { | ||
| 924 | let cursor = index + 2; | ||
| 925 | while (cursor < input.length) { | ||
| 926 | if (isCsiFinalByte(input.charCodeAt(cursor))) { | ||
| 927 | const sequence = input.slice(index, cursor + 1); | ||
| 928 | const body = input.slice(index + 2, cursor); | ||
| 929 | if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { | ||
| 930 | append(sequence); | ||
| 931 | } | ||
| 932 | index = cursor + 1; | ||
| 933 | break; | ||
| 934 | } | ||
| 935 | cursor += 1; | ||
| 936 | } | ||
| 937 | if (cursor >= input.length) { | ||
| 938 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 939 | } | ||
| 940 | continue; | ||
| 941 | } | ||
| 942 | |||
| 943 | if ( | ||
| 944 | nextCodePoint === 0x5d || | ||
| 945 | nextCodePoint === 0x50 || | ||
| 946 | nextCodePoint === 0x5e || | ||
| 947 | nextCodePoint === 0x5f | ||
| 948 | ) { | ||
| 949 | const terminatorIndex = findStringTerminatorIndex(input, index + 2); | ||
| 950 | if (terminatorIndex === null) { | ||
| 951 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 952 | } | ||
| 953 | const sequence = input.slice(index, terminatorIndex); | ||
| 954 | const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); | ||
| 955 | if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { | ||
| 956 | append(sequence); | ||
| 957 | } | ||
| 958 | index = terminatorIndex; | ||
| 959 | continue; | ||
| 960 | } | ||
| 961 | |||
| 962 | const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); | ||
| 963 | if (escapeSequenceEndIndex === null) { | ||
| 964 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 965 | } | ||
| 966 | append(input.slice(index, escapeSequenceEndIndex)); | ||
| 967 | index = escapeSequenceEndIndex; | ||
| 968 | continue; | ||
| 969 | } | ||
| 970 | |||
| 971 | if (codePoint === 0x9b) { | ||
| 972 | let cursor = index + 1; | ||
| 973 | while (cursor < input.length) { | ||
| 974 | if (isCsiFinalByte(input.charCodeAt(cursor))) { | ||
| 975 | const sequence = input.slice(index, cursor + 1); | ||
| 976 | const body = input.slice(index + 1, cursor); | ||
| 977 | if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { | ||
| 978 | append(sequence); | ||
| 979 | } | ||
| 980 | index = cursor + 1; | ||
| 981 | break; | ||
| 982 | } | ||
| 983 | cursor += 1; | ||
| 984 | } | ||
| 985 | if (cursor >= input.length) { | ||
| 986 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 987 | } | ||
| 988 | continue; | ||
| 989 | } | ||
| 990 | |||
| 991 | if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { | ||
| 992 | const terminatorIndex = findStringTerminatorIndex(input, index + 1); | ||
| 993 | if (terminatorIndex === null) { | ||
| 994 | return { visibleText, pendingControlSequence: input.slice(index) }; | ||
| 995 | } | ||
| 996 | const sequence = input.slice(index, terminatorIndex); | ||
| 997 | const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); | ||
| 998 | if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { | ||
| 999 | append(sequence); | ||
| 1000 | } | ||
| 1001 | index = terminatorIndex; | ||
| 1002 | continue; | ||
| 1003 | } | ||
| 1004 | |||
| 1005 | append(input[index] ?? ""); | ||
| 1006 | index += 1; | ||
| 1007 | } | ||
| 1008 | |||
| 1009 | return { visibleText, pendingControlSequence: "" }; | ||
| 1010 | } | ||
| 1011 | |||
| 1012 | function legacySafeThreadId(threadId: string): string { | ||
| 1013 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | ||
| 1014 | } | ||
| 1015 | |||
| 1016 | function toSafeThreadId(threadId: string): string { | ||
| 1017 | return `terminal_${Encoding.encodeBase64Url(threadId)}`; | ||
| 1018 | } | ||
| 1019 | |||
| 1020 | function toSafeTerminalId(terminalId: string): string { | ||
| 1021 | return Encoding.encodeBase64Url(terminalId); | ||
| 1022 | } | ||
| 1023 | |||
| 1024 | function toSessionKey(threadId: string, terminalId: string): string { | ||
| 1025 | return `${threadId}\u0000${terminalId}`; | ||
| 1026 | } | ||
| 1027 | |||
| 1028 | function shouldExcludeTerminalEnvKey(key: string): boolean { | ||
| 1029 | const normalizedKey = key.toUpperCase(); | ||
| 1030 | if (normalizedKey.startsWith("T3CODE_")) { | ||
| 1031 | return true; | ||
| 1032 | } | ||
| 1033 | if (normalizedKey.startsWith("VITE_")) { | ||
| 1034 | return true; | ||
| 1035 | } | ||
| 1036 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); | ||
| 1037 | } | ||
| 1038 | |||
| 1039 | function createTerminalSpawnEnv( | ||
| 1040 | baseEnv: NodeJS.ProcessEnv, | ||
| 1041 | runtimeEnv?: Record<string, string> | null, | ||
| 1042 | ): NodeJS.ProcessEnv { | ||
| 1043 | const spawnEnv: NodeJS.ProcessEnv = {}; | ||
| 1044 | for (const [key, value] of Object.entries(baseEnv)) { | ||
| 1045 | if (value === undefined) continue; | ||
| 1046 | if (shouldExcludeTerminalEnvKey(key)) continue; | ||
| 1047 | spawnEnv[key] = value; | ||
| 1048 | } | ||
| 1049 | if (runtimeEnv) { | ||
| 1050 | for (const [key, value] of Object.entries(runtimeEnv)) { | ||
| 1051 | spawnEnv[key] = value; | ||
| 1052 | } | ||
| 1053 | } | ||
| 1054 | return spawnEnv; | ||
| 1055 | } | ||
| 1056 | |||
| 1057 | function normalizedRuntimeEnv( | ||
| 1058 | env: Record<string, string> | undefined, | ||
| 1059 | ): Record<string, string> | null { | ||
| 1060 | if (!env) return null; | ||
| 1061 | const entries = Object.entries(env); | ||
| 1062 | if (entries.length === 0) return null; | ||
| 1063 | return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); | ||
| 1064 | } | ||
| 1065 | |||
| 1066 | interface TerminalManagerOptions { | ||
| 1067 | logsDir: string; | ||
| 1068 | historyLineLimit?: number; | ||
| 1069 | ptyAdapter: PtyAdapter.PtyAdapter["Service"]; | ||
| 1070 | shellResolver?: () => string; | ||
| 1071 | env?: NodeJS.ProcessEnv; | ||
| 1072 | subprocessInspector?: TerminalSubprocessInspector; | ||
| 1073 | subprocessPollIntervalMs?: number; | ||
| 1074 | processKillGraceMs?: number; | ||
| 1075 | maxRetainedInactiveSessions?: number; | ||
| 1076 | registerTerminalProcesses?: (input: { | ||
| 1077 | readonly threadId: string; | ||
| 1078 | readonly terminalId: string; | ||
| 1079 | readonly processIds: ReadonlyArray<number>; | ||
| 1080 | }) => Effect.Effect<void>; | ||
| 1081 | unregisterTerminal?: (input: { | ||
| 1082 | readonly threadId: string; | ||
| 1083 | readonly terminalId: string; | ||
| 1084 | }) => Effect.Effect<void>; | ||
| 1085 | } | ||
| 1086 | |||
| 1087 | export const make = Effect.fn("TerminalManager.make")(function* () { | ||
| 1088 | const { terminalLogsDir } = yield* ServerConfig.ServerConfig; | ||
| 1089 | const ptyAdapter = yield* PtyAdapter.PtyAdapter; | ||
| 1090 | const portDiscovery = yield* PortScanner.PortDiscovery; | ||
| 1091 | return yield* makeWithOptions({ | ||
| 1092 | logsDir: terminalLogsDir, | ||
| 1093 | ptyAdapter, | ||
| 1094 | registerTerminalProcesses: portDiscovery.registerTerminalProcesses, | ||
| 1095 | unregisterTerminal: portDiscovery.unregisterTerminal, | ||
| 1096 | }); | ||
| 1097 | }); | ||
| 1098 | |||
| 1099 | export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(function* ( | ||
| 1100 | options: TerminalManagerOptions, | ||
| 1101 | ) { | ||
| 1102 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1103 | const path = yield* Path.Path; | ||
| 1104 | const context = yield* Effect.context<never>(); | ||
| 1105 | const runFork = Effect.runForkWith(context); | ||
| 1106 | |||
| 1107 | const logsDir = options.logsDir; | ||
| 1108 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | ||
| 1109 | const platform = yield* HostProcessPlatform; | ||
| 1110 | // Terminals must inherit the user's full environment (minus the blocklist | ||
| 1111 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips | ||
| 1112 | // things like PSModulePath, DISPLAY, proxies, and toolchain variables. | ||
| 1113 | // `options.env` is the test seam. | ||
| 1114 | const baseEnv = options.env ?? process.env; | ||
| 1115 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); | ||
| 1116 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 1117 | const subprocessInspector = | ||
| 1118 | options.subprocessInspector ?? | ||
| 1119 | ((terminalPid) => | ||
| 1120 | defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( | ||
| 1121 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 1122 | )); | ||
| 1123 | const subprocessPollIntervalMs = | ||
| 1124 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | ||
| 1125 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; | ||
| 1126 | const maxRetainedInactiveSessions = | ||
| 1127 | options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; | ||
| 1128 | const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); | ||
| 1129 | const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); | ||
| 1130 | |||
| 1131 | yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); | ||
| 1132 | |||
| 1133 | const managerStateRef = yield* SynchronizedRef.make<TerminalManagerState>({ | ||
| 1134 | sessions: new Map(), | ||
| 1135 | killFibers: new Map(), | ||
| 1136 | }); | ||
| 1137 | const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>()); | ||
| 1138 | const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>(); | ||
| 1139 | const workerScope = yield* Scope.make("sequential"); | ||
| 1140 | yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); | ||
| 1141 | |||
| 1142 | const publishEvent = (event: TerminalEvent) => | ||
| 1143 | Effect.gen(function* () { | ||
| 1144 | for (const listener of terminalEventListeners) { | ||
| 1145 | yield* listener(event).pipe(Effect.ignoreCause({ log: true })); | ||
| 1146 | } | ||
| 1147 | }); | ||
| 1148 | |||
| 1149 | const historyPath = (threadId: string, terminalId: string) => { | ||
| 1150 | const threadPart = toSafeThreadId(threadId); | ||
| 1151 | if (terminalId === DEFAULT_TERMINAL_ID) { | ||
| 1152 | return path.join(logsDir, `${threadPart}.log`); | ||
| 1153 | } | ||
| 1154 | return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); | ||
| 1155 | }; | ||
| 1156 | |||
| 1157 | const legacyHistoryPath = (threadId: string) => | ||
| 1158 | path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); | ||
| 1159 | |||
| 1160 | const toTerminalHistoryError = | ||
| 1161 | (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => | ||
| 1162 | (cause: unknown) => | ||
| 1163 | new TerminalHistoryError({ | ||
| 1164 | operation, | ||
| 1165 | threadId, | ||
| 1166 | terminalId, | ||
| 1167 | cause, | ||
| 1168 | }); | ||
| 1169 | |||
| 1170 | const readManagerState = SynchronizedRef.get(managerStateRef); | ||
| 1171 | |||
| 1172 | const modifyManagerState = <A>( | ||
| 1173 | f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], | ||
| 1174 | ) => SynchronizedRef.modify(managerStateRef, f); | ||
| 1175 | |||
| 1176 | const getThreadSemaphore = (threadId: string) => | ||
| 1177 | SynchronizedRef.modifyEffect(threadLocksRef, (current) => { | ||
| 1178 | const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr( | ||
| 1179 | current.get(threadId), | ||
| 1180 | ); | ||
| 1181 | return Option.match(existing, { | ||
| 1182 | onNone: () => | ||
| 1183 | Semaphore.make(1).pipe( | ||
| 1184 | Effect.map((semaphore) => { | ||
| 1185 | const next = new Map(current); | ||
| 1186 | next.set(threadId, semaphore); | ||
| 1187 | return [semaphore, next] as const; | ||
| 1188 | }), | ||
| 1189 | ), | ||
| 1190 | onSome: (semaphore) => Effect.succeed([semaphore, current] as const), | ||
| 1191 | }); | ||
| 1192 | }); | ||
| 1193 | |||
| 1194 | const withThreadLock = <A, E, R>( | ||
| 1195 | threadId: string, | ||
| 1196 | effect: Effect.Effect<A, E, R>, | ||
| 1197 | ): Effect.Effect<A, E, R> => | ||
| 1198 | Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); | ||
| 1199 | |||
| 1200 | const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( | ||
| 1201 | process: PtyAdapter.PtyProcess | null, | ||
| 1202 | ) { | ||
| 1203 | if (!process) return; | ||
| 1204 | const fiber: Option.Option<Fiber.Fiber<void, never>> = yield* modifyManagerState< | ||
| 1205 | Option.Option<Fiber.Fiber<void, never>> | ||
| 1206 | >((state) => { | ||
| 1207 | const existing: Option.Option<Fiber.Fiber<void, never>> = Option.fromNullishOr( | ||
| 1208 | state.killFibers.get(process), | ||
| 1209 | ); | ||
| 1210 | if (Option.isNone(existing)) { | ||
| 1211 | return [Option.none<Fiber.Fiber<void, never>>(), state] as const; | ||
| 1212 | } | ||
| 1213 | const killFibers = new Map(state.killFibers); | ||
| 1214 | killFibers.delete(process); | ||
| 1215 | return [existing, { ...state, killFibers }] as const; | ||
| 1216 | }); | ||
| 1217 | if (Option.isSome(fiber)) { | ||
| 1218 | yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore); | ||
| 1219 | } | ||
| 1220 | }); | ||
| 1221 | |||
| 1222 | const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* ( | ||
| 1223 | process: PtyAdapter.PtyProcess, | ||
| 1224 | fiber: Fiber.Fiber<void, never>, | ||
| 1225 | ) { | ||
| 1226 | yield* modifyManagerState((state) => { | ||
| 1227 | const killFibers = new Map(state.killFibers); | ||
| 1228 | killFibers.set(process, fiber); | ||
| 1229 | return [undefined, { ...state, killFibers }] as const; | ||
| 1230 | }); | ||
| 1231 | }); | ||
| 1232 | |||
| 1233 | const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( | ||
| 1234 | process: PtyAdapter.PtyProcess, | ||
| 1235 | threadId: string, | ||
| 1236 | terminalId: string, | ||
| 1237 | ) { | ||
| 1238 | const terminated = yield* Effect.try({ | ||
| 1239 | try: () => process.kill("SIGTERM"), | ||
| 1240 | catch: (cause) => | ||
| 1241 | new TerminalProcessSignalError({ | ||
| 1242 | cause, | ||
| 1243 | signal: "SIGTERM", | ||
| 1244 | terminalPid: process.pid, | ||
| 1245 | }), | ||
| 1246 | }).pipe( | ||
| 1247 | Effect.as(true), | ||
| 1248 | Effect.catch((error) => | ||
| 1249 | Effect.logWarning("failed to kill terminal process", { | ||
| 1250 | threadId, | ||
| 1251 | terminalId, | ||
| 1252 | signal: "SIGTERM", | ||
| 1253 | error: error.message, | ||
| 1254 | }).pipe(Effect.as(false)), | ||
| 1255 | ), | ||
| 1256 | ); | ||
| 1257 | if (!terminated) { | ||
| 1258 | return; | ||
| 1259 | } | ||
| 1260 | |||
| 1261 | yield* Effect.sleep(processKillGraceMs); | ||
| 1262 | |||
| 1263 | yield* Effect.try({ | ||
| 1264 | try: () => process.kill("SIGKILL"), | ||
| 1265 | catch: (cause) => | ||
| 1266 | new TerminalProcessSignalError({ | ||
| 1267 | cause, | ||
| 1268 | signal: "SIGKILL", | ||
| 1269 | terminalPid: process.pid, | ||
| 1270 | }), | ||
| 1271 | }).pipe( | ||
| 1272 | Effect.catch((error) => | ||
| 1273 | Effect.logWarning("failed to force-kill terminal process", { | ||
| 1274 | threadId, | ||
| 1275 | terminalId, | ||
| 1276 | signal: "SIGKILL", | ||
| 1277 | error: error.message, | ||
| 1278 | }), | ||
| 1279 | ), | ||
| 1280 | ); | ||
| 1281 | }); | ||
| 1282 | |||
| 1283 | const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( | ||
| 1284 | process: PtyAdapter.PtyProcess, | ||
| 1285 | threadId: string, | ||
| 1286 | terminalId: string, | ||
| 1287 | ) { | ||
| 1288 | const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( | ||
| 1289 | Effect.ensuring( | ||
| 1290 | modifyManagerState((state) => { | ||
| 1291 | if (!state.killFibers.has(process)) { | ||
| 1292 | return [undefined, state] as const; | ||
| 1293 | } | ||
| 1294 | const killFibers = new Map(state.killFibers); | ||
| 1295 | killFibers.delete(process); | ||
| 1296 | return [undefined, { ...state, killFibers }] as const; | ||
| 1297 | }), | ||
| 1298 | ), | ||
| 1299 | Effect.forkIn(workerScope), | ||
| 1300 | ); | ||
| 1301 | |||
| 1302 | yield* registerKillFiber(process, fiber); | ||
| 1303 | }); | ||
| 1304 | |||
| 1305 | const persistWorker = yield* makeKeyedCoalescingWorker< | ||
| 1306 | string, | ||
| 1307 | PersistHistoryRequest, | ||
| 1308 | never, | ||
| 1309 | never | ||
| 1310 | >({ | ||
| 1311 | merge: (current, next) => ({ | ||
| 1312 | history: next.history, | ||
| 1313 | immediate: current.immediate || next.immediate, | ||
| 1314 | }), | ||
| 1315 | process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { | ||
| 1316 | if (!request.immediate) { | ||
| 1317 | yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); | ||
| 1318 | } | ||
| 1319 | |||
| 1320 | const [threadId, terminalId] = sessionKey.split("\u0000"); | ||
| 1321 | if (!threadId || !terminalId) { | ||
| 1322 | return; | ||
| 1323 | } | ||
| 1324 | |||
| 1325 | yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( | ||
| 1326 | Effect.catch((error) => | ||
| 1327 | Effect.logWarning("failed to persist terminal history", { | ||
| 1328 | threadId, | ||
| 1329 | terminalId, | ||
| 1330 | error, | ||
| 1331 | }), | ||
| 1332 | ), | ||
| 1333 | ); | ||
| 1334 | }), | ||
| 1335 | }); | ||
| 1336 | |||
| 1337 | const queuePersist = Effect.fn("terminal.queuePersist")(function* ( | ||
| 1338 | threadId: string, | ||
| 1339 | terminalId: string, | ||
| 1340 | history: string, | ||
| 1341 | ) { | ||
| 1342 | yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { | ||
| 1343 | history, | ||
| 1344 | immediate: false, | ||
| 1345 | }); | ||
| 1346 | }); | ||
| 1347 | |||
| 1348 | const flushPersist = Effect.fn("terminal.flushPersist")(function* ( | ||
| 1349 | threadId: string, | ||
| 1350 | terminalId: string, | ||
| 1351 | ) { | ||
| 1352 | yield* persistWorker.drainKey(toSessionKey(threadId, terminalId)); | ||
| 1353 | }); | ||
| 1354 | |||
| 1355 | const persistHistory = Effect.fn("terminal.persistHistory")(function* ( | ||
| 1356 | threadId: string, | ||
| 1357 | terminalId: string, | ||
| 1358 | history: string, | ||
| 1359 | ) { | ||
| 1360 | yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { | ||
| 1361 | history, | ||
| 1362 | immediate: true, | ||
| 1363 | }); | ||
| 1364 | yield* flushPersist(threadId, terminalId); | ||
| 1365 | }); | ||
| 1366 | |||
| 1367 | const readHistory = Effect.fn("terminal.readHistory")(function* ( | ||
| 1368 | threadId: string, | ||
| 1369 | terminalId: string, | ||
| 1370 | ) { | ||
| 1371 | const nextPath = historyPath(threadId, terminalId); | ||
| 1372 | if ( | ||
| 1373 | yield* fileSystem | ||
| 1374 | .exists(nextPath) | ||
| 1375 | .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) | ||
| 1376 | ) { | ||
| 1377 | const raw = yield* fileSystem | ||
| 1378 | .readFileString(nextPath) | ||
| 1379 | .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); | ||
| 1380 | const capped = capHistory(raw, historyLineLimit); | ||
| 1381 | if (capped !== raw) { | ||
| 1382 | yield* fileSystem | ||
| 1383 | .writeFileString(nextPath, capped) | ||
| 1384 | .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); | ||
| 1385 | } | ||
| 1386 | return capped; | ||
| 1387 | } | ||
| 1388 | |||
| 1389 | if (terminalId !== DEFAULT_TERMINAL_ID) { | ||
| 1390 | return ""; | ||
| 1391 | } | ||
| 1392 | |||
| 1393 | const legacyPath = legacyHistoryPath(threadId); | ||
| 1394 | if ( | ||
| 1395 | !(yield* fileSystem | ||
| 1396 | .exists(legacyPath) | ||
| 1397 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) | ||
| 1398 | ) { | ||
| 1399 | return ""; | ||
| 1400 | } | ||
| 1401 | |||
| 1402 | const raw = yield* fileSystem | ||
| 1403 | .readFileString(legacyPath) | ||
| 1404 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); | ||
| 1405 | const capped = capHistory(raw, historyLineLimit); | ||
| 1406 | yield* fileSystem | ||
| 1407 | .writeFileString(nextPath, capped) | ||
| 1408 | .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); | ||
| 1409 | yield* fileSystem.remove(legacyPath, { force: true }).pipe( | ||
| 1410 | Effect.catch((cleanupError) => | ||
| 1411 | Effect.logWarning("failed to remove legacy terminal history", { | ||
| 1412 | threadId, | ||
| 1413 | error: cleanupError, | ||
| 1414 | }), | ||
| 1415 | ), | ||
| 1416 | ); | ||
| 1417 | return capped; | ||
| 1418 | }); | ||
| 1419 | |||
| 1420 | const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( | ||
| 1421 | threadId: string, | ||
| 1422 | terminalId: string, | ||
| 1423 | ) { | ||
| 1424 | yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( | ||
| 1425 | Effect.catch((error) => | ||
| 1426 | Effect.logWarning("failed to delete terminal history", { | ||
| 1427 | threadId, | ||
| 1428 | terminalId, | ||
| 1429 | error, | ||
| 1430 | }), | ||
| 1431 | ), | ||
| 1432 | ); | ||
| 1433 | if (terminalId === DEFAULT_TERMINAL_ID) { | ||
| 1434 | yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( | ||
| 1435 | Effect.catch((error) => | ||
| 1436 | Effect.logWarning("failed to delete terminal history", { | ||
| 1437 | threadId, | ||
| 1438 | terminalId, | ||
| 1439 | error, | ||
| 1440 | }), | ||
| 1441 | ), | ||
| 1442 | ); | ||
| 1443 | } | ||
| 1444 | }); | ||
| 1445 | |||
| 1446 | const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( | ||
| 1447 | threadId: string, | ||
| 1448 | ) { | ||
| 1449 | const threadPrefix = `${toSafeThreadId(threadId)}_`; | ||
| 1450 | const entries = yield* fileSystem | ||
| 1451 | .readDirectory(logsDir, { recursive: false }) | ||
| 1452 | .pipe(Effect.orElseSucceed(() => [] as Array<string>)); | ||
| 1453 | yield* Effect.forEach( | ||
| 1454 | entries.filter( | ||
| 1455 | (name) => | ||
| 1456 | name === `${toSafeThreadId(threadId)}.log` || | ||
| 1457 | name === `${legacySafeThreadId(threadId)}.log` || | ||
| 1458 | name.startsWith(threadPrefix), | ||
| 1459 | ), | ||
| 1460 | (name) => | ||
| 1461 | fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( | ||
| 1462 | Effect.catch((error) => | ||
| 1463 | Effect.logWarning("failed to delete terminal histories for thread", { | ||
| 1464 | threadId, | ||
| 1465 | error, | ||
| 1466 | }), | ||
| 1467 | ), | ||
| 1468 | ), | ||
| 1469 | { discard: true }, | ||
| 1470 | ); | ||
| 1471 | }); | ||
| 1472 | |||
| 1473 | const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { | ||
| 1474 | const stats = yield* fileSystem.stat(cwd).pipe( | ||
| 1475 | Effect.mapError( | ||
| 1476 | (cause) => | ||
| 1477 | new TerminalCwdError({ | ||
| 1478 | cwd, | ||
| 1479 | reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", | ||
| 1480 | cause, | ||
| 1481 | }), | ||
| 1482 | ), | ||
| 1483 | ); | ||
| 1484 | if (stats.type !== "Directory") { | ||
| 1485 | return yield* new TerminalCwdError({ | ||
| 1486 | cwd, | ||
| 1487 | reason: "notDirectory", | ||
| 1488 | }); | ||
| 1489 | } | ||
| 1490 | }); | ||
| 1491 | |||
| 1492 | const getSession = Effect.fn("terminal.getSession")(function* ( | ||
| 1493 | threadId: string, | ||
| 1494 | terminalId: string, | ||
| 1495 | ): Effect.fn.Return<Option.Option<TerminalSessionState>> { | ||
| 1496 | return yield* Effect.map(readManagerState, (state) => | ||
| 1497 | Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), | ||
| 1498 | ); | ||
| 1499 | }); | ||
| 1500 | |||
| 1501 | const requireSession = Effect.fn("terminal.requireSession")(function* ( | ||
| 1502 | threadId: string, | ||
| 1503 | terminalId: string, | ||
| 1504 | ): Effect.fn.Return<TerminalSessionState, TerminalSessionLookupError> { | ||
| 1505 | return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => | ||
| 1506 | Option.match(session, { | ||
| 1507 | onNone: () => | ||
| 1508 | Effect.fail( | ||
| 1509 | new TerminalSessionLookupError({ | ||
| 1510 | threadId, | ||
| 1511 | terminalId, | ||
| 1512 | }), | ||
| 1513 | ), | ||
| 1514 | onSome: Effect.succeed, | ||
| 1515 | }), | ||
| 1516 | ); | ||
| 1517 | }); | ||
| 1518 | |||
| 1519 | const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { | ||
| 1520 | return yield* readManagerState.pipe( | ||
| 1521 | Effect.map((state) => | ||
| 1522 | [...state.sessions.values()].filter((session) => session.threadId === threadId), | ||
| 1523 | ), | ||
| 1524 | ); | ||
| 1525 | }); | ||
| 1526 | |||
| 1527 | const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( | ||
| 1528 | function* () { | ||
| 1529 | yield* modifyManagerState((state) => { | ||
| 1530 | const inactiveSessions = [...state.sessions.values()].filter( | ||
| 1531 | (session) => session.status !== "running", | ||
| 1532 | ); | ||
| 1533 | if (inactiveSessions.length <= maxRetainedInactiveSessions) { | ||
| 1534 | return [undefined, state] as const; | ||
| 1535 | } | ||
| 1536 | |||
| 1537 | inactiveSessions.sort( | ||
| 1538 | (left, right) => | ||
| 1539 | left.updatedAt.localeCompare(right.updatedAt) || | ||
| 1540 | left.threadId.localeCompare(right.threadId) || | ||
| 1541 | left.terminalId.localeCompare(right.terminalId), | ||
| 1542 | ); | ||
| 1543 | |||
| 1544 | const sessions = new Map(state.sessions); | ||
| 1545 | |||
| 1546 | const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; | ||
| 1547 | for (const session of inactiveSessions.slice(0, toEvict)) { | ||
| 1548 | const key = toSessionKey(session.threadId, session.terminalId); | ||
| 1549 | sessions.delete(key); | ||
| 1550 | } | ||
| 1551 | |||
| 1552 | return [undefined, { ...state, sessions }] as const; | ||
| 1553 | }); | ||
| 1554 | }, | ||
| 1555 | ); | ||
| 1556 | |||
| 1557 | const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( | ||
| 1558 | session: TerminalSessionState, | ||
| 1559 | expectedPid: number, | ||
| 1560 | ) { | ||
| 1561 | while (true) { | ||
| 1562 | const action: DrainProcessEventAction = yield* Effect.sync(() => { | ||
| 1563 | if (session.pid !== expectedPid || !session.process || session.status !== "running") { | ||
| 1564 | session.pendingProcessEvents = []; | ||
| 1565 | session.pendingProcessEventIndex = 0; | ||
| 1566 | session.processEventDrainRunning = false; | ||
| 1567 | return { type: "idle" } as const; | ||
| 1568 | } | ||
| 1569 | |||
| 1570 | const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; | ||
| 1571 | if (!nextEvent) { | ||
| 1572 | session.pendingProcessEvents = []; | ||
| 1573 | session.pendingProcessEventIndex = 0; | ||
| 1574 | session.processEventDrainRunning = false; | ||
| 1575 | return { type: "idle" } as const; | ||
| 1576 | } | ||
| 1577 | |||
| 1578 | session.pendingProcessEventIndex += 1; | ||
| 1579 | if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { | ||
| 1580 | session.pendingProcessEvents = []; | ||
| 1581 | session.pendingProcessEventIndex = 0; | ||
| 1582 | } | ||
| 1583 | |||
| 1584 | if (nextEvent.type === "output") { | ||
| 1585 | const sanitized = sanitizeTerminalHistoryChunk( | ||
| 1586 | session.pendingHistoryControlSequence, | ||
| 1587 | nextEvent.data, | ||
| 1588 | ); | ||
| 1589 | session.pendingHistoryControlSequence = sanitized.pendingControlSequence; | ||
| 1590 | if (sanitized.visibleText.length > 0) { | ||
| 1591 | session.history = capHistory( | ||
| 1592 | `${session.history}${sanitized.visibleText}`, | ||
| 1593 | historyLineLimit, | ||
| 1594 | ); | ||
| 1595 | } | ||
| 1596 | const eventStamp = advanceEventSequence(session); | ||
| 1597 | |||
| 1598 | return { | ||
| 1599 | type: "output", | ||
| 1600 | threadId: session.threadId, | ||
| 1601 | terminalId: session.terminalId, | ||
| 1602 | sequence: eventStamp.sequence, | ||
| 1603 | history: sanitized.visibleText.length > 0 ? session.history : null, | ||
| 1604 | data: nextEvent.data, | ||
| 1605 | } as const; | ||
| 1606 | } | ||
| 1607 | |||
| 1608 | const process = session.process; | ||
| 1609 | cleanupProcessHandles(session); | ||
| 1610 | session.process = null; | ||
| 1611 | session.pid = null; | ||
| 1612 | session.hasRunningSubprocess = false; | ||
| 1613 | session.childCommandLabel = null; | ||
| 1614 | session.status = "exited"; | ||
| 1615 | session.pendingHistoryControlSequence = ""; | ||
| 1616 | session.pendingProcessEvents = []; | ||
| 1617 | session.pendingProcessEventIndex = 0; | ||
| 1618 | session.processEventDrainRunning = false; | ||
| 1619 | session.exitCode = Number.isInteger(nextEvent.event.exitCode) | ||
| 1620 | ? nextEvent.event.exitCode | ||
| 1621 | : null; | ||
| 1622 | session.exitSignal = Number.isInteger(nextEvent.event.signal) | ||
| 1623 | ? nextEvent.event.signal | ||
| 1624 | : null; | ||
| 1625 | const eventStamp = advanceEventSequence(session); | ||
| 1626 | |||
| 1627 | return { | ||
| 1628 | type: "exit", | ||
| 1629 | process, | ||
| 1630 | threadId: session.threadId, | ||
| 1631 | terminalId: session.terminalId, | ||
| 1632 | sequence: eventStamp.sequence, | ||
| 1633 | exitCode: session.exitCode, | ||
| 1634 | exitSignal: session.exitSignal, | ||
| 1635 | } as const; | ||
| 1636 | }); | ||
| 1637 | |||
| 1638 | if (action.type === "idle") { | ||
| 1639 | return; | ||
| 1640 | } | ||
| 1641 | |||
| 1642 | if (action.type === "output") { | ||
| 1643 | if (action.history !== null) { | ||
| 1644 | yield* queuePersist(action.threadId, action.terminalId, action.history); | ||
| 1645 | } | ||
| 1646 | |||
| 1647 | yield* publishEvent({ | ||
| 1648 | type: "output", | ||
| 1649 | threadId: action.threadId, | ||
| 1650 | terminalId: action.terminalId, | ||
| 1651 | sequence: action.sequence, | ||
| 1652 | data: action.data, | ||
| 1653 | }); | ||
| 1654 | continue; | ||
| 1655 | } | ||
| 1656 | |||
| 1657 | yield* clearKillFiber(action.process); | ||
| 1658 | yield* unregisterTerminal({ | ||
| 1659 | threadId: action.threadId, | ||
| 1660 | terminalId: action.terminalId, | ||
| 1661 | }); | ||
| 1662 | yield* publishEvent({ | ||
| 1663 | type: "exited", | ||
| 1664 | threadId: action.threadId, | ||
| 1665 | terminalId: action.terminalId, | ||
| 1666 | sequence: action.sequence, | ||
| 1667 | exitCode: action.exitCode, | ||
| 1668 | exitSignal: action.exitSignal, | ||
| 1669 | }); | ||
| 1670 | yield* evictInactiveSessionsIfNeeded(); | ||
| 1671 | return; | ||
| 1672 | } | ||
| 1673 | }); | ||
| 1674 | |||
| 1675 | const stopProcess = Effect.fn("terminal.stopProcess")(function* (session: TerminalSessionState) { | ||
| 1676 | const process = session.process; | ||
| 1677 | if (!process) return; | ||
| 1678 | |||
| 1679 | const updatedAt = yield* nowIso; | ||
| 1680 | yield* modifyManagerState((state) => { | ||
| 1681 | cleanupProcessHandles(session); | ||
| 1682 | session.process = null; | ||
| 1683 | session.pid = null; | ||
| 1684 | session.hasRunningSubprocess = false; | ||
| 1685 | session.childCommandLabel = null; | ||
| 1686 | session.status = "exited"; | ||
| 1687 | session.pendingHistoryControlSequence = ""; | ||
| 1688 | session.pendingProcessEvents = []; | ||
| 1689 | session.pendingProcessEventIndex = 0; | ||
| 1690 | session.processEventDrainRunning = false; | ||
| 1691 | session.updatedAt = updatedAt; | ||
| 1692 | return [undefined, state] as const; | ||
| 1693 | }); | ||
| 1694 | |||
| 1695 | yield* clearKillFiber(process); | ||
| 1696 | yield* unregisterTerminal({ | ||
| 1697 | threadId: session.threadId, | ||
| 1698 | terminalId: session.terminalId, | ||
| 1699 | }); | ||
| 1700 | yield* startKillEscalation(process, session.threadId, session.terminalId); | ||
| 1701 | yield* evictInactiveSessionsIfNeeded(); | ||
| 1702 | }); | ||
| 1703 | |||
| 1704 | const trySpawn = Effect.fn("terminal.trySpawn")(function* ( | ||
| 1705 | shellCandidates: ReadonlyArray<ShellCandidate>, | ||
| 1706 | spawnEnv: NodeJS.ProcessEnv, | ||
| 1707 | session: TerminalSessionState, | ||
| 1708 | index = 0, | ||
| 1709 | lastError: PtyAdapter.PtySpawnError | null = null, | ||
| 1710 | ): Effect.fn.Return< | ||
| 1711 | { process: PtyAdapter.PtyProcess; shellLabel: string }, | ||
| 1712 | PtyAdapter.PtySpawnError | ||
| 1713 | > { | ||
| 1714 | if (index >= shellCandidates.length) { | ||
| 1715 | return yield* new PtyAdapter.PtySpawnError({ | ||
| 1716 | adapter: "terminal-manager", | ||
| 1717 | attemptedShells: shellCandidates.map((candidate) => formatShellCandidate(candidate)), | ||
| 1718 | ...(lastError ? { cause: lastError } : {}), | ||
| 1719 | }); | ||
| 1720 | } | ||
| 1721 | |||
| 1722 | const candidate = shellCandidates[index]; | ||
| 1723 | if (!candidate) { | ||
| 1724 | return yield* ( | ||
| 1725 | lastError ?? | ||
| 1726 | new PtyAdapter.PtySpawnError({ | ||
| 1727 | adapter: "terminal-manager", | ||
| 1728 | attemptedShells: [], | ||
| 1729 | }) | ||
| 1730 | ); | ||
| 1731 | } | ||
| 1732 | |||
| 1733 | const attempt = yield* Effect.result( | ||
| 1734 | options.ptyAdapter.spawn({ | ||
| 1735 | shell: candidate.shell, | ||
| 1736 | ...(candidate.args ? { args: candidate.args } : {}), | ||
| 1737 | cwd: session.cwd, | ||
| 1738 | cols: session.cols, | ||
| 1739 | rows: session.rows, | ||
| 1740 | env: spawnEnv, | ||
| 1741 | }), | ||
| 1742 | ); | ||
| 1743 | |||
| 1744 | if (attempt._tag === "Success") { | ||
| 1745 | return { | ||
| 1746 | process: attempt.success, | ||
| 1747 | shellLabel: formatShellCandidate(candidate), | ||
| 1748 | }; | ||
| 1749 | } | ||
| 1750 | |||
| 1751 | const spawnError = attempt.failure; | ||
| 1752 | if (!isRetryableShellSpawnError(spawnError)) { | ||
| 1753 | return yield* spawnError; | ||
| 1754 | } | ||
| 1755 | |||
| 1756 | return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); | ||
| 1757 | }); | ||
| 1758 | |||
| 1759 | const startSession = Effect.fn("terminal.startSession")(function* ( | ||
| 1760 | session: TerminalSessionState, | ||
| 1761 | input: TerminalStartInput, | ||
| 1762 | eventType: "started" | "restarted", | ||
| 1763 | ) { | ||
| 1764 | yield* stopProcess(session); | ||
| 1765 | yield* Effect.annotateCurrentSpan({ | ||
| 1766 | "terminal.thread_id": session.threadId, | ||
| 1767 | "terminal.id": session.terminalId, | ||
| 1768 | "terminal.event_type": eventType, | ||
| 1769 | "terminal.cwd": input.cwd, | ||
| 1770 | }); | ||
| 1771 | |||
| 1772 | const startingAt = yield* nowIso; | ||
| 1773 | yield* modifyManagerState((state) => { | ||
| 1774 | session.status = "starting"; | ||
| 1775 | session.cwd = input.cwd; | ||
| 1776 | session.worktreePath = input.worktreePath ?? null; | ||
| 1777 | session.cols = input.cols; | ||
| 1778 | session.rows = input.rows; | ||
| 1779 | session.exitCode = null; | ||
| 1780 | session.exitSignal = null; | ||
| 1781 | session.hasRunningSubprocess = false; | ||
| 1782 | session.childCommandLabel = null; | ||
| 1783 | session.pendingProcessEvents = []; | ||
| 1784 | session.pendingProcessEventIndex = 0; | ||
| 1785 | session.processEventDrainRunning = false; | ||
| 1786 | session.updatedAt = startingAt; | ||
| 1787 | return [undefined, state] as const; | ||
| 1788 | }); | ||
| 1789 | |||
| 1790 | let ptyProcess: PtyAdapter.PtyProcess | null = null; | ||
| 1791 | let startedShell: string | null = null; | ||
| 1792 | |||
| 1793 | const startResult = yield* Effect.result( | ||
| 1794 | increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( | ||
| 1795 | Effect.andThen( | ||
| 1796 | Effect.gen(function* () { | ||
| 1797 | const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); | ||
| 1798 | const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); | ||
| 1799 | const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); | ||
| 1800 | ptyProcess = spawnResult.process; | ||
| 1801 | startedShell = spawnResult.shellLabel; | ||
| 1802 | |||
| 1803 | const processPid = ptyProcess.pid; | ||
| 1804 | const unsubscribeData = ptyProcess.onData((data) => { | ||
| 1805 | if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { | ||
| 1806 | return; | ||
| 1807 | } | ||
| 1808 | runFork(drainProcessEvents(session, processPid)); | ||
| 1809 | }); | ||
| 1810 | const unsubscribeExit = ptyProcess.onExit((event) => { | ||
| 1811 | if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { | ||
| 1812 | return; | ||
| 1813 | } | ||
| 1814 | runFork(drainProcessEvents(session, processPid)); | ||
| 1815 | }); | ||
| 1816 | |||
| 1817 | let eventStamp: ReturnType<typeof advanceEventSequence> = { | ||
| 1818 | updatedAt: session.updatedAt, | ||
| 1819 | sequence: session.eventSequence, | ||
| 1820 | }; | ||
| 1821 | yield* modifyManagerState((state) => { | ||
| 1822 | session.process = ptyProcess; | ||
| 1823 | session.pid = processPid; | ||
| 1824 | session.status = "running"; | ||
| 1825 | session.unsubscribeData = unsubscribeData; | ||
| 1826 | session.unsubscribeExit = unsubscribeExit; | ||
| 1827 | eventStamp = advanceEventSequence(session); | ||
| 1828 | return [undefined, state] as const; | ||
| 1829 | }); | ||
| 1830 | |||
| 1831 | yield* publishEvent({ | ||
| 1832 | type: eventType, | ||
| 1833 | threadId: session.threadId, | ||
| 1834 | terminalId: session.terminalId, | ||
| 1835 | sequence: eventStamp.sequence, | ||
| 1836 | snapshot: snapshot(session), | ||
| 1837 | }); | ||
| 1838 | }), | ||
| 1839 | ), | ||
| 1840 | ), | ||
| 1841 | ); | ||
| 1842 | |||
| 1843 | if (startResult._tag === "Success") { | ||
| 1844 | return; | ||
| 1845 | } | ||
| 1846 | |||
| 1847 | { | ||
| 1848 | const error = startResult.failure; | ||
| 1849 | if (ptyProcess) { | ||
| 1850 | yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); | ||
| 1851 | } | ||
| 1852 | |||
| 1853 | yield* modifyManagerState((state) => { | ||
| 1854 | cleanupProcessHandles(session); | ||
| 1855 | session.status = "error"; | ||
| 1856 | session.pid = null; | ||
| 1857 | session.process = null; | ||
| 1858 | session.hasRunningSubprocess = false; | ||
| 1859 | session.childCommandLabel = null; | ||
| 1860 | session.pendingProcessEvents = []; | ||
| 1861 | session.pendingProcessEventIndex = 0; | ||
| 1862 | session.processEventDrainRunning = false; | ||
| 1863 | advanceEventSequence(session); | ||
| 1864 | return [undefined, state] as const; | ||
| 1865 | }); | ||
| 1866 | yield* unregisterTerminal({ | ||
| 1867 | threadId: session.threadId, | ||
| 1868 | terminalId: session.terminalId, | ||
| 1869 | }); | ||
| 1870 | |||
| 1871 | yield* evictInactiveSessionsIfNeeded(); | ||
| 1872 | |||
| 1873 | const message = error.message; | ||
| 1874 | yield* publishEvent({ | ||
| 1875 | type: "error", | ||
| 1876 | threadId: session.threadId, | ||
| 1877 | terminalId: session.terminalId, | ||
| 1878 | sequence: session.eventSequence, | ||
| 1879 | message, | ||
| 1880 | }); | ||
| 1881 | yield* Effect.logError("failed to start terminal", { | ||
| 1882 | threadId: session.threadId, | ||
| 1883 | terminalId: session.terminalId, | ||
| 1884 | error: message, | ||
| 1885 | ...(startedShell ? { shell: startedShell } : {}), | ||
| 1886 | }); | ||
| 1887 | } | ||
| 1888 | }); | ||
| 1889 | |||
| 1890 | const closeSession = Effect.fn("terminal.closeSession")(function* ( | ||
| 1891 | threadId: string, | ||
| 1892 | terminalId: string, | ||
| 1893 | deleteHistoryOnClose: boolean, | ||
| 1894 | ) { | ||
| 1895 | const key = toSessionKey(threadId, terminalId); | ||
| 1896 | const session = yield* getSession(threadId, terminalId); | ||
| 1897 | const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; | ||
| 1898 | |||
| 1899 | if (Option.isSome(session)) { | ||
| 1900 | yield* stopProcess(session.value); | ||
| 1901 | yield* unregisterTerminal({ threadId, terminalId }); | ||
| 1902 | yield* persistHistory(threadId, terminalId, session.value.history); | ||
| 1903 | } | ||
| 1904 | |||
| 1905 | yield* flushPersist(threadId, terminalId); | ||
| 1906 | |||
| 1907 | const removed = yield* modifyManagerState((state) => { | ||
| 1908 | if (!state.sessions.has(key)) { | ||
| 1909 | return [false, state] as const; | ||
| 1910 | } | ||
| 1911 | const sessions = new Map(state.sessions); | ||
| 1912 | sessions.delete(key); | ||
| 1913 | return [true, { ...state, sessions }] as const; | ||
| 1914 | }); | ||
| 1915 | |||
| 1916 | if (removed) { | ||
| 1917 | yield* publishEvent({ | ||
| 1918 | type: "closed", | ||
| 1919 | threadId, | ||
| 1920 | terminalId, | ||
| 1921 | sequence: closedEventSequence, | ||
| 1922 | }); | ||
| 1923 | } | ||
| 1924 | |||
| 1925 | if (deleteHistoryOnClose) { | ||
| 1926 | yield* deleteHistory(threadId, terminalId); | ||
| 1927 | } | ||
| 1928 | }); | ||
| 1929 | |||
| 1930 | const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { | ||
| 1931 | const state = yield* readManagerState; | ||
| 1932 | const runningSessions = [...state.sessions.values()].filter( | ||
| 1933 | (session): session is TerminalSessionState & { pid: number } => | ||
| 1934 | session.status === "running" && Number.isInteger(session.pid), | ||
| 1935 | ); | ||
| 1936 | |||
| 1937 | if (runningSessions.length === 0) { | ||
| 1938 | return; | ||
| 1939 | } | ||
| 1940 | |||
| 1941 | const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( | ||
| 1942 | session: TerminalSessionState & { pid: number }, | ||
| 1943 | ) { | ||
| 1944 | const terminalPid = session.pid; | ||
| 1945 | const inspectResult = yield* subprocessInspector(terminalPid).pipe( | ||
| 1946 | Effect.map(Option.some), | ||
| 1947 | Effect.catch((reason) => | ||
| 1948 | Effect.logWarning("failed to check terminal subprocess activity", { | ||
| 1949 | threadId: session.threadId, | ||
| 1950 | terminalId: session.terminalId, | ||
| 1951 | terminalPid, | ||
| 1952 | reason, | ||
| 1953 | }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())), | ||
| 1954 | ), | ||
| 1955 | ); | ||
| 1956 | |||
| 1957 | if (Option.isNone(inspectResult)) { | ||
| 1958 | return; | ||
| 1959 | } | ||
| 1960 | |||
| 1961 | const next = inspectResult.value; | ||
| 1962 | yield* registerTerminalProcesses({ | ||
| 1963 | threadId: session.threadId, | ||
| 1964 | terminalId: session.terminalId, | ||
| 1965 | processIds: next.processIds, | ||
| 1966 | }); | ||
| 1967 | const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; | ||
| 1968 | const event = yield* modifyManagerState((state) => { | ||
| 1969 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( | ||
| 1970 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), | ||
| 1971 | ); | ||
| 1972 | if ( | ||
| 1973 | Option.isNone(liveSession) || | ||
| 1974 | liveSession.value.status !== "running" || | ||
| 1975 | liveSession.value.pid !== terminalPid || | ||
| 1976 | (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && | ||
| 1977 | liveSession.value.childCommandLabel === nextChildLabel) | ||
| 1978 | ) { | ||
| 1979 | return [Option.none(), state] as const; | ||
| 1980 | } | ||
| 1981 | |||
| 1982 | liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; | ||
| 1983 | liveSession.value.childCommandLabel = nextChildLabel; | ||
| 1984 | const eventStamp = advanceEventSequence(liveSession.value); | ||
| 1985 | |||
| 1986 | return [ | ||
| 1987 | Option.some({ | ||
| 1988 | type: "activity" as const, | ||
| 1989 | threadId: liveSession.value.threadId, | ||
| 1990 | terminalId: liveSession.value.terminalId, | ||
| 1991 | sequence: eventStamp.sequence, | ||
| 1992 | hasRunningSubprocess: next.hasRunningSubprocess, | ||
| 1993 | label: terminalWireLabel(liveSession.value), | ||
| 1994 | }), | ||
| 1995 | state, | ||
| 1996 | ] as const; | ||
| 1997 | }); | ||
| 1998 | |||
| 1999 | if (Option.isSome(event)) { | ||
| 2000 | yield* publishEvent(event.value); | ||
| 2001 | } | ||
| 2002 | }); | ||
| 2003 | |||
| 2004 | yield* Effect.forEach(runningSessions, checkSubprocessActivity, { | ||
| 2005 | concurrency: "unbounded", | ||
| 2006 | discard: true, | ||
| 2007 | }); | ||
| 2008 | }); | ||
| 2009 | |||
| 2010 | const hasRunningSessions = readManagerState.pipe( | ||
| 2011 | Effect.map((state) => | ||
| 2012 | [...state.sessions.values()].some((session) => session.status === "running"), | ||
| 2013 | ), | ||
| 2014 | ); | ||
| 2015 | |||
| 2016 | yield* Effect.forever( | ||
| 2017 | hasRunningSessions.pipe( | ||
| 2018 | Effect.flatMap((active) => | ||
| 2019 | active | ||
| 2020 | ? pollSubprocessActivity().pipe( | ||
| 2021 | Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), | ||
| 2022 | ) | ||
| 2023 | : Effect.sleep(subprocessPollIntervalMs), | ||
| 2024 | ), | ||
| 2025 | ), | ||
| 2026 | ).pipe(Effect.forkIn(workerScope)); | ||
| 2027 | |||
| 2028 | yield* Effect.addFinalizer(() => | ||
| 2029 | Effect.gen(function* () { | ||
| 2030 | const sessions = yield* modifyManagerState( | ||
| 2031 | (state) => | ||
| 2032 | [ | ||
| 2033 | [...state.sessions.values()], | ||
| 2034 | { | ||
| 2035 | ...state, | ||
| 2036 | sessions: new Map(), | ||
| 2037 | }, | ||
| 2038 | ] as const, | ||
| 2039 | ); | ||
| 2040 | |||
| 2041 | const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( | ||
| 2042 | session: TerminalSessionState, | ||
| 2043 | ) { | ||
| 2044 | cleanupProcessHandles(session); | ||
| 2045 | if (!session.process) return; | ||
| 2046 | yield* clearKillFiber(session.process); | ||
| 2047 | yield* runKillEscalation(session.process, session.threadId, session.terminalId); | ||
| 2048 | }); | ||
| 2049 | |||
| 2050 | yield* Effect.forEach(sessions, cleanupSession, { | ||
| 2051 | concurrency: "unbounded", | ||
| 2052 | discard: true, | ||
| 2053 | }); | ||
| 2054 | }).pipe(Effect.ignoreCause({ log: true })), | ||
| 2055 | ); | ||
| 2056 | |||
| 2057 | const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { | ||
| 2058 | const terminalId = input.terminalId; | ||
| 2059 | yield* assertValidCwd(input.cwd); | ||
| 2060 | |||
| 2061 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 2062 | const existing = yield* getSession(input.threadId, terminalId); | ||
| 2063 | if (Option.isNone(existing)) { | ||
| 2064 | yield* flushPersist(input.threadId, terminalId); | ||
| 2065 | const history = yield* readHistory(input.threadId, terminalId); | ||
| 2066 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | ||
| 2067 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | ||
| 2068 | const session: TerminalSessionState = { | ||
| 2069 | threadId: input.threadId, | ||
| 2070 | terminalId, | ||
| 2071 | cwd: input.cwd, | ||
| 2072 | worktreePath: input.worktreePath ?? null, | ||
| 2073 | status: "starting", | ||
| 2074 | pid: null, | ||
| 2075 | history, | ||
| 2076 | pendingHistoryControlSequence: "", | ||
| 2077 | pendingProcessEvents: [], | ||
| 2078 | pendingProcessEventIndex: 0, | ||
| 2079 | processEventDrainRunning: false, | ||
| 2080 | exitCode: null, | ||
| 2081 | exitSignal: null, | ||
| 2082 | updatedAt: yield* nowIso, | ||
| 2083 | eventSequence: 0, | ||
| 2084 | cols, | ||
| 2085 | rows, | ||
| 2086 | process: null, | ||
| 2087 | unsubscribeData: null, | ||
| 2088 | unsubscribeExit: null, | ||
| 2089 | hasRunningSubprocess: false, | ||
| 2090 | childCommandLabel: null, | ||
| 2091 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 2092 | }; | ||
| 2093 | |||
| 2094 | const createdSession = session; | ||
| 2095 | yield* modifyManagerState((state) => { | ||
| 2096 | const sessions = new Map(state.sessions); | ||
| 2097 | sessions.set(sessionKey, createdSession); | ||
| 2098 | return [undefined, { ...state, sessions }] as const; | ||
| 2099 | }); | ||
| 2100 | |||
| 2101 | yield* evictInactiveSessionsIfNeeded(); | ||
| 2102 | yield* startSession( | ||
| 2103 | session, | ||
| 2104 | { | ||
| 2105 | threadId: input.threadId, | ||
| 2106 | terminalId, | ||
| 2107 | cwd: input.cwd, | ||
| 2108 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 2109 | cols, | ||
| 2110 | rows, | ||
| 2111 | ...(input.env ? { env: input.env } : {}), | ||
| 2112 | }, | ||
| 2113 | "started", | ||
| 2114 | ); | ||
| 2115 | return snapshot(session); | ||
| 2116 | } | ||
| 2117 | |||
| 2118 | const liveSession = existing.value; | ||
| 2119 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | ||
| 2120 | const currentRuntimeEnv = liveSession.runtimeEnv; | ||
| 2121 | const targetCols = input.cols ?? liveSession.cols; | ||
| 2122 | const targetRows = input.rows ?? liveSession.rows; | ||
| 2123 | const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); | ||
| 2124 | const nextWorktreePath = | ||
| 2125 | input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; | ||
| 2126 | const launchContextChanged = | ||
| 2127 | liveSession.cwd !== input.cwd || | ||
| 2128 | runtimeEnvChanged || | ||
| 2129 | liveSession.worktreePath !== nextWorktreePath; | ||
| 2130 | |||
| 2131 | if (launchContextChanged) { | ||
| 2132 | yield* stopProcess(liveSession); | ||
| 2133 | liveSession.cwd = input.cwd; | ||
| 2134 | liveSession.worktreePath = nextWorktreePath; | ||
| 2135 | liveSession.runtimeEnv = nextRuntimeEnv; | ||
| 2136 | liveSession.history = ""; | ||
| 2137 | liveSession.pendingHistoryControlSequence = ""; | ||
| 2138 | liveSession.pendingProcessEvents = []; | ||
| 2139 | liveSession.pendingProcessEventIndex = 0; | ||
| 2140 | liveSession.processEventDrainRunning = false; | ||
| 2141 | yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); | ||
| 2142 | } else if (liveSession.status === "exited" || liveSession.status === "error") { | ||
| 2143 | liveSession.runtimeEnv = nextRuntimeEnv; | ||
| 2144 | liveSession.worktreePath = nextWorktreePath; | ||
| 2145 | liveSession.history = ""; | ||
| 2146 | liveSession.pendingHistoryControlSequence = ""; | ||
| 2147 | liveSession.pendingProcessEvents = []; | ||
| 2148 | liveSession.pendingProcessEventIndex = 0; | ||
| 2149 | liveSession.processEventDrainRunning = false; | ||
| 2150 | yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); | ||
| 2151 | } | ||
| 2152 | |||
| 2153 | if (!liveSession.process) { | ||
| 2154 | yield* startSession( | ||
| 2155 | liveSession, | ||
| 2156 | { | ||
| 2157 | threadId: input.threadId, | ||
| 2158 | terminalId, | ||
| 2159 | cwd: input.cwd, | ||
| 2160 | worktreePath: liveSession.worktreePath, | ||
| 2161 | cols: targetCols, | ||
| 2162 | rows: targetRows, | ||
| 2163 | ...(input.env ? { env: input.env } : {}), | ||
| 2164 | }, | ||
| 2165 | "started", | ||
| 2166 | ); | ||
| 2167 | return snapshot(liveSession); | ||
| 2168 | } | ||
| 2169 | |||
| 2170 | if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { | ||
| 2171 | liveSession.cols = targetCols; | ||
| 2172 | liveSession.rows = targetRows; | ||
| 2173 | liveSession.updatedAt = yield* nowIso; | ||
| 2174 | liveSession.process.resize(targetCols, targetRows); | ||
| 2175 | } | ||
| 2176 | |||
| 2177 | return snapshot(liveSession); | ||
| 2178 | }); | ||
| 2179 | |||
| 2180 | const open: TerminalManager["Service"]["open"] = (input) => | ||
| 2181 | withThreadLock(input.threadId, openLocked(input)); | ||
| 2182 | |||
| 2183 | const openOrAttachForStream = (input: TerminalAttachInput) => | ||
| 2184 | withThreadLock( | ||
| 2185 | input.threadId, | ||
| 2186 | Effect.gen(function* () { | ||
| 2187 | const terminalId = input.terminalId; | ||
| 2188 | const existing = yield* getSession(input.threadId, terminalId); | ||
| 2189 | |||
| 2190 | if (Option.isNone(existing)) { | ||
| 2191 | if (!input.cwd) { | ||
| 2192 | return yield* new TerminalSessionLookupError({ | ||
| 2193 | threadId: input.threadId, | ||
| 2194 | terminalId, | ||
| 2195 | }); | ||
| 2196 | } | ||
| 2197 | |||
| 2198 | return yield* openLocked({ | ||
| 2199 | ...input, | ||
| 2200 | terminalId, | ||
| 2201 | cwd: input.cwd, | ||
| 2202 | }); | ||
| 2203 | } | ||
| 2204 | |||
| 2205 | const session = existing.value; | ||
| 2206 | const targetCols = input.cols ?? session.cols; | ||
| 2207 | const targetRows = input.rows ?? session.rows; | ||
| 2208 | |||
| 2209 | if (!session.process && input.cwd && input.restartIfNotRunning === true) { | ||
| 2210 | return yield* openLocked({ | ||
| 2211 | ...input, | ||
| 2212 | terminalId, | ||
| 2213 | cwd: input.cwd, | ||
| 2214 | }); | ||
| 2215 | } | ||
| 2216 | |||
| 2217 | if ( | ||
| 2218 | session.process && | ||
| 2219 | session.status === "running" && | ||
| 2220 | (session.cols !== targetCols || session.rows !== targetRows) | ||
| 2221 | ) { | ||
| 2222 | session.cols = targetCols; | ||
| 2223 | session.rows = targetRows; | ||
| 2224 | session.updatedAt = yield* nowIso; | ||
| 2225 | yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); | ||
| 2226 | } | ||
| 2227 | |||
| 2228 | return snapshot(session); | ||
| 2229 | }), | ||
| 2230 | ); | ||
| 2231 | |||
| 2232 | const readAllTerminalMetadata = () => | ||
| 2233 | readManagerState.pipe( | ||
| 2234 | Effect.map((state) => | ||
| 2235 | [...state.sessions.values()] | ||
| 2236 | .map(summary) | ||
| 2237 | .sort( | ||
| 2238 | (left, right) => | ||
| 2239 | right.updatedAt.localeCompare(left.updatedAt) || | ||
| 2240 | left.threadId.localeCompare(right.threadId) || | ||
| 2241 | left.terminalId.localeCompare(right.terminalId), | ||
| 2242 | ), | ||
| 2243 | ), | ||
| 2244 | ); | ||
| 2245 | |||
| 2246 | const readTerminalMetadata = (input: { | ||
| 2247 | readonly threadId: string; | ||
| 2248 | readonly terminalId: string; | ||
| 2249 | }) => | ||
| 2250 | getSession(input.threadId, input.terminalId).pipe( | ||
| 2251 | Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), | ||
| 2252 | ); | ||
| 2253 | |||
| 2254 | const subscribe: TerminalManager["Service"]["subscribe"] = (listener) => | ||
| 2255 | Effect.sync(() => { | ||
| 2256 | terminalEventListeners.add(listener); | ||
| 2257 | return () => { | ||
| 2258 | terminalEventListeners.delete(listener); | ||
| 2259 | }; | ||
| 2260 | }); | ||
| 2261 | |||
| 2262 | const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => { | ||
| 2263 | let unsubscribe: (() => void) | null = null; | ||
| 2264 | |||
| 2265 | return Effect.gen(function* () { | ||
| 2266 | const bufferedEvents: TerminalEvent[] = []; | ||
| 2267 | let deliverLive = false; | ||
| 2268 | |||
| 2269 | unsubscribe = yield* subscribe((event) => { | ||
| 2270 | if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { | ||
| 2271 | return Effect.void; | ||
| 2272 | } | ||
| 2273 | |||
| 2274 | if (!deliverLive) { | ||
| 2275 | bufferedEvents.push(event); | ||
| 2276 | return Effect.void; | ||
| 2277 | } | ||
| 2278 | |||
| 2279 | const attachEvent = terminalEventToAttachEvent(event); | ||
| 2280 | return attachEvent ? listener(attachEvent) : Effect.void; | ||
| 2281 | }); | ||
| 2282 | |||
| 2283 | const initialSnapshot = yield* openOrAttachForStream(input); | ||
| 2284 | |||
| 2285 | yield* listener({ | ||
| 2286 | type: "snapshot", | ||
| 2287 | snapshot: initialSnapshot, | ||
| 2288 | }); | ||
| 2289 | |||
| 2290 | for (const event of bufferedEvents) { | ||
| 2291 | if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { | ||
| 2292 | continue; | ||
| 2293 | } | ||
| 2294 | |||
| 2295 | const attachEvent = terminalEventToAttachEvent(event); | ||
| 2296 | if (attachEvent) { | ||
| 2297 | yield* listener(attachEvent); | ||
| 2298 | } | ||
| 2299 | } | ||
| 2300 | |||
| 2301 | deliverLive = true; | ||
| 2302 | return () => { | ||
| 2303 | unsubscribe?.(); | ||
| 2304 | unsubscribe = null; | ||
| 2305 | }; | ||
| 2306 | }).pipe( | ||
| 2307 | Effect.catchCause((cause) => | ||
| 2308 | Effect.flatMap( | ||
| 2309 | Effect.sync(() => { | ||
| 2310 | unsubscribe?.(); | ||
| 2311 | unsubscribe = null; | ||
| 2312 | }), | ||
| 2313 | () => Effect.failCause(cause), | ||
| 2314 | ), | ||
| 2315 | ), | ||
| 2316 | ); | ||
| 2317 | }; | ||
| 2318 | |||
| 2319 | const metadataEventFromTerminalEvent = ( | ||
| 2320 | event: TerminalEvent, | ||
| 2321 | ): Effect.Effect<TerminalMetadataStreamEvent | null> => { | ||
| 2322 | if (!shouldPublishTerminalMetadataEvent(event)) { | ||
| 2323 | return Effect.succeed(null); | ||
| 2324 | } | ||
| 2325 | |||
| 2326 | if (event.type === "closed") { | ||
| 2327 | return Effect.succeed({ | ||
| 2328 | type: "remove" as const, | ||
| 2329 | threadId: event.threadId, | ||
| 2330 | terminalId: event.terminalId, | ||
| 2331 | }); | ||
| 2332 | } | ||
| 2333 | |||
| 2334 | return readTerminalMetadata({ | ||
| 2335 | threadId: event.threadId, | ||
| 2336 | terminalId: event.terminalId, | ||
| 2337 | }).pipe( | ||
| 2338 | Effect.map((terminal) => | ||
| 2339 | terminal | ||
| 2340 | ? { | ||
| 2341 | type: "upsert" as const, | ||
| 2342 | terminal, | ||
| 2343 | } | ||
| 2344 | : null, | ||
| 2345 | ), | ||
| 2346 | ); | ||
| 2347 | }; | ||
| 2348 | |||
| 2349 | const offerMetadataEvent = ( | ||
| 2350 | listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>, | ||
| 2351 | event: TerminalEvent, | ||
| 2352 | ) => | ||
| 2353 | metadataEventFromTerminalEvent(event).pipe( | ||
| 2354 | Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), | ||
| 2355 | ); | ||
| 2356 | |||
| 2357 | const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => { | ||
| 2358 | let unsubscribe: (() => void) | null = null; | ||
| 2359 | |||
| 2360 | return Effect.gen(function* () { | ||
| 2361 | const bufferedEvents: TerminalEvent[] = []; | ||
| 2362 | let deliverLive = false; | ||
| 2363 | |||
| 2364 | unsubscribe = yield* subscribe((event) => { | ||
| 2365 | if (!deliverLive) { | ||
| 2366 | bufferedEvents.push(event); | ||
| 2367 | return Effect.void; | ||
| 2368 | } | ||
| 2369 | |||
| 2370 | return offerMetadataEvent(listener, event); | ||
| 2371 | }); | ||
| 2372 | |||
| 2373 | const terminals = yield* readAllTerminalMetadata(); | ||
| 2374 | yield* listener({ | ||
| 2375 | type: "snapshot", | ||
| 2376 | terminals, | ||
| 2377 | }); | ||
| 2378 | |||
| 2379 | for (const event of bufferedEvents) { | ||
| 2380 | yield* offerMetadataEvent(listener, event); | ||
| 2381 | } | ||
| 2382 | |||
| 2383 | deliverLive = true; | ||
| 2384 | return () => { | ||
| 2385 | unsubscribe?.(); | ||
| 2386 | unsubscribe = null; | ||
| 2387 | }; | ||
| 2388 | }).pipe( | ||
| 2389 | Effect.catchCause((cause) => | ||
| 2390 | Effect.flatMap( | ||
| 2391 | Effect.sync(() => { | ||
| 2392 | unsubscribe?.(); | ||
| 2393 | unsubscribe = null; | ||
| 2394 | }), | ||
| 2395 | () => Effect.failCause(cause), | ||
| 2396 | ), | ||
| 2397 | ), | ||
| 2398 | ); | ||
| 2399 | }; | ||
| 2400 | |||
| 2401 | const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) { | ||
| 2402 | const terminalId = input.terminalId; | ||
| 2403 | const session = yield* requireSession(input.threadId, terminalId); | ||
| 2404 | const process = session.process; | ||
| 2405 | if (!process || session.status !== "running") { | ||
| 2406 | if (session.status === "exited") return; | ||
| 2407 | return yield* new TerminalNotRunningError({ | ||
| 2408 | threadId: input.threadId, | ||
| 2409 | terminalId, | ||
| 2410 | }); | ||
| 2411 | } | ||
| 2412 | yield* Effect.sync(() => process.write(input.data)); | ||
| 2413 | }); | ||
| 2414 | |||
| 2415 | const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { | ||
| 2416 | const session = yield* getSession(input.threadId, input.terminalId); | ||
| 2417 | // ResizeObserver traffic can already be in flight when the UI closes the session. | ||
| 2418 | if (Option.isNone(session)) { | ||
| 2419 | return; | ||
| 2420 | } | ||
| 2421 | const process = session.value.process; | ||
| 2422 | if (!process || session.value.status !== "running") { | ||
| 2423 | return; | ||
| 2424 | } | ||
| 2425 | session.value.cols = input.cols; | ||
| 2426 | session.value.rows = input.rows; | ||
| 2427 | session.value.updatedAt = yield* nowIso; | ||
| 2428 | yield* Effect.sync(() => process.resize(input.cols, input.rows)); | ||
| 2429 | }); | ||
| 2430 | |||
| 2431 | const resize: TerminalManager["Service"]["resize"] = (input) => | ||
| 2432 | withThreadLock(input.threadId, resizeLocked(input)); | ||
| 2433 | |||
| 2434 | const clear: TerminalManager["Service"]["clear"] = (input) => | ||
| 2435 | withThreadLock( | ||
| 2436 | input.threadId, | ||
| 2437 | Effect.gen(function* () { | ||
| 2438 | const terminalId = input.terminalId; | ||
| 2439 | const session = yield* requireSession(input.threadId, terminalId); | ||
| 2440 | session.history = ""; | ||
| 2441 | session.pendingHistoryControlSequence = ""; | ||
| 2442 | session.pendingProcessEvents = []; | ||
| 2443 | session.pendingProcessEventIndex = 0; | ||
| 2444 | session.processEventDrainRunning = false; | ||
| 2445 | const eventStamp = advanceEventSequence(session); | ||
| 2446 | yield* persistHistory(input.threadId, terminalId, session.history); | ||
| 2447 | yield* publishEvent({ | ||
| 2448 | type: "cleared", | ||
| 2449 | threadId: input.threadId, | ||
| 2450 | terminalId, | ||
| 2451 | sequence: eventStamp.sequence, | ||
| 2452 | }); | ||
| 2453 | }), | ||
| 2454 | ); | ||
| 2455 | |||
| 2456 | const restart: TerminalManager["Service"]["restart"] = (input) => | ||
| 2457 | withThreadLock( | ||
| 2458 | input.threadId, | ||
| 2459 | Effect.gen(function* () { | ||
| 2460 | yield* increment(terminalRestartsTotal, { scope: "thread" }); | ||
| 2461 | const terminalId = input.terminalId; | ||
| 2462 | yield* assertValidCwd(input.cwd); | ||
| 2463 | |||
| 2464 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 2465 | const existingSession = yield* getSession(input.threadId, terminalId); | ||
| 2466 | let session: TerminalSessionState; | ||
| 2467 | if (Option.isNone(existingSession)) { | ||
| 2468 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | ||
| 2469 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | ||
| 2470 | session = { | ||
| 2471 | threadId: input.threadId, | ||
| 2472 | terminalId, | ||
| 2473 | cwd: input.cwd, | ||
| 2474 | worktreePath: input.worktreePath ?? null, | ||
| 2475 | status: "starting", | ||
| 2476 | pid: null, | ||
| 2477 | history: "", | ||
| 2478 | pendingHistoryControlSequence: "", | ||
| 2479 | pendingProcessEvents: [], | ||
| 2480 | pendingProcessEventIndex: 0, | ||
| 2481 | processEventDrainRunning: false, | ||
| 2482 | exitCode: null, | ||
| 2483 | exitSignal: null, | ||
| 2484 | updatedAt: yield* nowIso, | ||
| 2485 | eventSequence: 0, | ||
| 2486 | cols, | ||
| 2487 | rows, | ||
| 2488 | process: null, | ||
| 2489 | unsubscribeData: null, | ||
| 2490 | unsubscribeExit: null, | ||
| 2491 | hasRunningSubprocess: false, | ||
| 2492 | childCommandLabel: null, | ||
| 2493 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 2494 | }; | ||
| 2495 | const createdSession = session; | ||
| 2496 | yield* modifyManagerState((state) => { | ||
| 2497 | const sessions = new Map(state.sessions); | ||
| 2498 | sessions.set(sessionKey, createdSession); | ||
| 2499 | return [undefined, { ...state, sessions }] as const; | ||
| 2500 | }); | ||
| 2501 | yield* evictInactiveSessionsIfNeeded(); | ||
| 2502 | } else { | ||
| 2503 | session = existingSession.value; | ||
| 2504 | yield* stopProcess(session); | ||
| 2505 | session.cwd = input.cwd; | ||
| 2506 | session.worktreePath = input.worktreePath ?? null; | ||
| 2507 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | ||
| 2508 | } | ||
| 2509 | |||
| 2510 | const cols = input.cols ?? session.cols; | ||
| 2511 | const rows = input.rows ?? session.rows; | ||
| 2512 | |||
| 2513 | session.history = ""; | ||
| 2514 | session.pendingHistoryControlSequence = ""; | ||
| 2515 | session.pendingProcessEvents = []; | ||
| 2516 | session.pendingProcessEventIndex = 0; | ||
| 2517 | session.processEventDrainRunning = false; | ||
| 2518 | yield* persistHistory(input.threadId, terminalId, session.history); | ||
| 2519 | yield* startSession( | ||
| 2520 | session, | ||
| 2521 | { | ||
| 2522 | threadId: input.threadId, | ||
| 2523 | terminalId, | ||
| 2524 | cwd: input.cwd, | ||
| 2525 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 2526 | cols, | ||
| 2527 | rows, | ||
| 2528 | ...(input.env ? { env: input.env } : {}), | ||
| 2529 | }, | ||
| 2530 | "restarted", | ||
| 2531 | ); | ||
| 2532 | return snapshot(session); | ||
| 2533 | }), | ||
| 2534 | ); | ||
| 2535 | |||
| 2536 | const close: TerminalManager["Service"]["close"] = (input) => | ||
| 2537 | withThreadLock( | ||
| 2538 | input.threadId, | ||
| 2539 | Effect.gen(function* () { | ||
| 2540 | if (input.terminalId) { | ||
| 2541 | yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); | ||
| 2542 | return; | ||
| 2543 | } | ||
| 2544 | |||
| 2545 | const threadSessions = yield* sessionsForThread(input.threadId); | ||
| 2546 | yield* Effect.forEach( | ||
| 2547 | threadSessions, | ||
| 2548 | (session) => closeSession(input.threadId, session.terminalId, false), | ||
| 2549 | { discard: true }, | ||
| 2550 | ); | ||
| 2551 | |||
| 2552 | if (input.deleteHistory) { | ||
| 2553 | yield* deleteAllHistoryForThread(input.threadId); | ||
| 2554 | } | ||
| 2555 | }), | ||
| 2556 | ); | ||
| 2557 | |||
| 2558 | return TerminalManager.of({ | ||
| 2559 | open, | ||
| 2560 | attachStream, | ||
| 2561 | write, | ||
| 2562 | resize, | ||
| 2563 | clear, | ||
| 2564 | restart, | ||
| 2565 | close, | ||
| 2566 | subscribe, | ||
| 2567 | subscribeMetadata, | ||
| 2568 | }); | ||
| 2569 | }); | ||
| 2570 | |||
| 2571 | export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer)); | ||
apps/server/src/terminal/NodePtyAdapter.test.ts created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import { vi } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import * as NodePtyAdapter from "./NodePtyAdapter.ts"; | ||
| 9 | import * as PtyAdapter from "./PtyAdapter.ts"; | ||
| 10 | |||
| 11 | const spawn = vi.fn(() => ({ | ||
| 12 | pid: 42, | ||
| 13 | write: vi.fn(), | ||
| 14 | resize: vi.fn(), | ||
| 15 | kill: vi.fn(), | ||
| 16 | onData: vi.fn(() => ({ dispose: vi.fn() })), | ||
| 17 | onExit: vi.fn(() => ({ dispose: vi.fn() })), | ||
| 18 | })); | ||
| 19 | |||
| 20 | vi.mock("node-pty", () => ({ spawn })); | ||
| 21 | |||
| 22 | const testLayer = NodePtyAdapter.layer.pipe( | ||
| 23 | Layer.provide( | ||
| 24 | Layer.mergeAll( | ||
| 25 | NodeServices.layer, | ||
| 26 | Layer.succeed(HostProcessPlatform, "win32"), | ||
| 27 | Layer.succeed(HostProcessArchitecture, "x64"), | ||
| 28 | ), | ||
| 29 | ), | ||
| 30 | ); | ||
| 31 | |||
| 32 | it.effect("spawns through the public adapter with the provided host references", () => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const adapter = yield* PtyAdapter.PtyAdapter; | ||
| 35 | const process = yield* adapter.spawn({ | ||
| 36 | shell: "powershell.exe", | ||
| 37 | args: ["-NoLogo"], | ||
| 38 | cwd: "C:\\workspace", | ||
| 39 | cols: 120, | ||
| 40 | rows: 40, | ||
| 41 | env: {}, | ||
| 42 | }); | ||
| 43 | |||
| 44 | assert.equal(process.pid, 42); | ||
| 45 | assert.equal(spawn.mock.calls.length, 1); | ||
| 46 | assert.deepEqual(spawn.mock.calls[0], [ | ||
| 47 | "powershell.exe", | ||
| 48 | ["-NoLogo"], | ||
| 49 | { | ||
| 50 | cwd: "C:\\workspace", | ||
| 51 | cols: 120, | ||
| 52 | rows: 40, | ||
| 53 | env: {}, | ||
| 54 | name: "xterm-color", | ||
| 55 | }, | ||
| 56 | ]); | ||
| 57 | }).pipe(Effect.provide(testLayer)), | ||
| 58 | ); | ||
apps/server/src/terminal/NodePtyAdapter.ts created+139| ... | @@ -0,0 +1,139 @@ | ||
| 1 | import { createRequire } from "node:module"; | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | |||
| 9 | import * as PtyAdapter from "./PtyAdapter.ts"; | ||
| 10 | |||
| 11 | let didEnsureSpawnHelperExecutable = false; | ||
| 12 | |||
| 13 | const resolveNodePtySpawnHelperPath = Effect.gen(function* () { | ||
| 14 | const requireForNodePty = createRequire(import.meta.url); | ||
| 15 | const path = yield* Path.Path; | ||
| 16 | const fs = yield* FileSystem.FileSystem; | ||
| 17 | const platform = yield* HostProcessPlatform; | ||
| 18 | const architecture = yield* HostProcessArchitecture; | ||
| 19 | |||
| 20 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | ||
| 21 | const packageDir = path.dirname(packageJsonPath); | ||
| 22 | const candidates = [ | ||
| 23 | path.join(packageDir, "build", "Release", "spawn-helper"), | ||
| 24 | path.join(packageDir, "build", "Debug", "spawn-helper"), | ||
| 25 | path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), | ||
| 26 | ]; | ||
| 27 | |||
| 28 | for (const candidate of candidates) { | ||
| 29 | if (yield* fs.exists(candidate)) { | ||
| 30 | return candidate; | ||
| 31 | } | ||
| 32 | } | ||
| 33 | return null; | ||
| 34 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 35 | |||
| 36 | const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { | ||
| 37 | const fs = yield* FileSystem.FileSystem; | ||
| 38 | const platform = yield* HostProcessPlatform; | ||
| 39 | if (platform === "win32") return; | ||
| 40 | if (didEnsureSpawnHelperExecutable) return; | ||
| 41 | |||
| 42 | const helperPath = yield* resolveNodePtySpawnHelperPath; | ||
| 43 | if (!helperPath) return; | ||
| 44 | didEnsureSpawnHelperExecutable = true; | ||
| 45 | |||
| 46 | if (!(yield* fs.exists(helperPath))) { | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | |||
| 50 | // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. | ||
| 51 | yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); | ||
| 52 | }); | ||
| 53 | |||
| 54 | class NodePtyProcess implements PtyAdapter.PtyProcess { | ||
| 55 | private readonly process: import("node-pty").IPty; | ||
| 56 | |||
| 57 | constructor(process: import("node-pty").IPty) { | ||
| 58 | this.process = process; | ||
| 59 | } | ||
| 60 | |||
| 61 | get pid(): number { | ||
| 62 | return this.process.pid; | ||
| 63 | } | ||
| 64 | |||
| 65 | write(data: string): void { | ||
| 66 | this.process.write(data); | ||
| 67 | } | ||
| 68 | |||
| 69 | resize(cols: number, rows: number): void { | ||
| 70 | this.process.resize(cols, rows); | ||
| 71 | } | ||
| 72 | |||
| 73 | kill(signal?: string): void { | ||
| 74 | this.process.kill(signal); | ||
| 75 | } | ||
| 76 | |||
| 77 | onData(callback: (data: string) => void): () => void { | ||
| 78 | const disposable = this.process.onData(callback); | ||
| 79 | return () => { | ||
| 80 | disposable.dispose(); | ||
| 81 | }; | ||
| 82 | } | ||
| 83 | |||
| 84 | onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { | ||
| 85 | const disposable = this.process.onExit((event) => { | ||
| 86 | callback({ | ||
| 87 | exitCode: event.exitCode, | ||
| 88 | signal: event.signal ?? null, | ||
| 89 | }); | ||
| 90 | }); | ||
| 91 | return () => { | ||
| 92 | disposable.dispose(); | ||
| 93 | }; | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | export const make = Effect.fn("NodePtyAdapter.make")(function* () { | ||
| 98 | const fs = yield* FileSystem.FileSystem; | ||
| 99 | const path = yield* Path.Path; | ||
| 100 | const platform = yield* HostProcessPlatform; | ||
| 101 | const architecture = yield* HostProcessArchitecture; | ||
| 102 | |||
| 103 | const nodePty = yield* Effect.promise(() => import("node-pty")); | ||
| 104 | |||
| 105 | const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( | ||
| 106 | ensureNodePtySpawnHelperExecutable().pipe( | ||
| 107 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 108 | Effect.provideService(Path.Path, path), | ||
| 109 | Effect.provideService(HostProcessPlatform, platform), | ||
| 110 | Effect.provideService(HostProcessArchitecture, architecture), | ||
| 111 | Effect.orElseSucceed(() => undefined), | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | |||
| 115 | return PtyAdapter.PtyAdapter.of({ | ||
| 116 | spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { | ||
| 117 | yield* ensureNodePtySpawnHelperExecutableCached; | ||
| 118 | const ptyProcess = yield* Effect.try({ | ||
| 119 | try: () => | ||
| 120 | nodePty.spawn(input.shell, input.args ?? [], { | ||
| 121 | cwd: input.cwd, | ||
| 122 | cols: input.cols, | ||
| 123 | rows: input.rows, | ||
| 124 | env: input.env, | ||
| 125 | name: platform === "win32" ? "xterm-color" : "xterm-256color", | ||
| 126 | }), | ||
| 127 | catch: (cause) => | ||
| 128 | new PtyAdapter.PtySpawnError({ | ||
| 129 | adapter: "node-pty", | ||
| 130 | shell: input.shell, | ||
| 131 | cause, | ||
| 132 | }), | ||
| 133 | }); | ||
| 134 | return new NodePtyProcess(ptyProcess); | ||
| 135 | }), | ||
| 136 | }); | ||
| 137 | }); | ||
| 138 | |||
| 139 | export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); | ||
apps/server/src/terminal/PtyAdapter.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | /** | ||
| 2 | * PtyAdapter - Terminal PTY adapter service contract. | ||
| 3 | * | ||
| 4 | * Defines the process primitives required by terminal session management | ||
| 5 | * without binding to a specific PTY implementation. | ||
| 6 | * | ||
| 7 | * @module PtyAdapter | ||
| 8 | */ | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | |||
| 13 | /** | ||
| 14 | * PtySpawnError - Error type for PTY spawn failures. | ||
| 15 | */ | ||
| 16 | export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", { | ||
| 17 | adapter: Schema.String, | ||
| 18 | shell: Schema.optional(Schema.String), | ||
| 19 | attemptedShells: Schema.optional(Schema.Array(Schema.String)), | ||
| 20 | cause: Schema.optional(Schema.Defect()), | ||
| 21 | }) { | ||
| 22 | override get message(): string { | ||
| 23 | const shell = this.shell === undefined ? "" : ` '${this.shell}'`; | ||
| 24 | const attemptedShells = | ||
| 25 | this.attemptedShells === undefined || this.attemptedShells.length === 0 | ||
| 26 | ? "" | ||
| 27 | : ` Tried shells: ${this.attemptedShells.join(", ")}.`; | ||
| 28 | const causeMessage = | ||
| 29 | this.cause instanceof Error && this.cause.message.length > 0 ? ` ${this.cause.message}` : ""; | ||
| 30 | return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}${causeMessage}`; | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface PtyExitEvent { | ||
| 35 | exitCode: number; | ||
| 36 | signal: number | null; | ||
| 37 | } | ||
| 38 | |||
| 39 | export interface PtyProcess { | ||
| 40 | readonly pid: number; | ||
| 41 | write(data: string): void; | ||
| 42 | resize(cols: number, rows: number): void; | ||
| 43 | kill(signal?: string): void; | ||
| 44 | onData(callback: (data: string) => void): () => void; | ||
| 45 | onExit(callback: (event: PtyExitEvent) => void): () => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface PtySpawnInput { | ||
| 49 | shell: string; | ||
| 50 | args?: string[]; | ||
| 51 | cwd: string; | ||
| 52 | cols: number; | ||
| 53 | rows: number; | ||
| 54 | env: NodeJS.ProcessEnv; | ||
| 55 | } | ||
| 56 | |||
| 57 | /** | ||
| 58 | * PtyAdapter - Service tag for PTY process integration. | ||
| 59 | */ | ||
| 60 | export class PtyAdapter extends Context.Service< | ||
| 61 | PtyAdapter, | ||
| 62 | { | ||
| 63 | /** | ||
| 64 | * Spawn a PTY process for a terminal session. | ||
| 65 | */ | ||
| 66 | readonly spawn: (input: PtySpawnInput) => Effect.Effect<PtyProcess, PtySpawnError>; | ||
| 67 | } | ||
| 68 | >()("t3/terminal/PtyAdapter") {} | ||
apps/server/src/terminal/Services/Manager.ts deleted-150| ... | @@ -1,150 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * TerminalManager - Terminal session orchestration service interface. | ||
| 3 | * | ||
| 4 | * Owns terminal lifecycle operations, output fanout, and session state | ||
| 5 | * transitions for thread-scoped terminals. | ||
| 6 | * | ||
| 7 | * @module TerminalManager | ||
| 8 | */ | ||
| 9 | import { | ||
| 10 | TerminalAttachInput, | ||
| 11 | TerminalAttachStreamEvent, | ||
| 12 | TerminalClearInput, | ||
| 13 | TerminalCloseInput, | ||
| 14 | TerminalEvent, | ||
| 15 | TerminalCwdError, | ||
| 16 | TerminalError, | ||
| 17 | TerminalHistoryError, | ||
| 18 | TerminalMetadataStreamEvent, | ||
| 19 | TerminalNotRunningError, | ||
| 20 | TerminalOpenInput, | ||
| 21 | TerminalResizeInput, | ||
| 22 | TerminalRestartInput, | ||
| 23 | TerminalSessionSnapshot, | ||
| 24 | TerminalSessionLookupError, | ||
| 25 | TerminalSessionStatus, | ||
| 26 | TerminalWriteInput, | ||
| 27 | } from "@t3tools/contracts"; | ||
| 28 | import type { PtyProcess } from "./PTY.ts"; | ||
| 29 | import * as Effect from "effect/Effect"; | ||
| 30 | import * as Context from "effect/Context"; | ||
| 31 | |||
| 32 | export { | ||
| 33 | TerminalCwdError, | ||
| 34 | TerminalError, | ||
| 35 | TerminalHistoryError, | ||
| 36 | TerminalNotRunningError, | ||
| 37 | TerminalSessionLookupError, | ||
| 38 | }; | ||
| 39 | |||
| 40 | export interface TerminalSessionState { | ||
| 41 | threadId: string; | ||
| 42 | terminalId: string; | ||
| 43 | cwd: string; | ||
| 44 | worktreePath: string | null; | ||
| 45 | status: TerminalSessionStatus; | ||
| 46 | pid: number | null; | ||
| 47 | history: string; | ||
| 48 | pendingHistoryControlSequence: string; | ||
| 49 | exitCode: number | null; | ||
| 50 | exitSignal: number | null; | ||
| 51 | updatedAt: string; | ||
| 52 | cols: number; | ||
| 53 | rows: number; | ||
| 54 | process: PtyProcess | null; | ||
| 55 | unsubscribeData: (() => void) | null; | ||
| 56 | unsubscribeExit: (() => void) | null; | ||
| 57 | hasRunningSubprocess: boolean; | ||
| 58 | runtimeEnv: Record<string, string> | null; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface ShellCandidate { | ||
| 62 | shell: string; | ||
| 63 | args?: string[]; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface TerminalStartInput extends TerminalOpenInput { | ||
| 67 | cols: number; | ||
| 68 | rows: number; | ||
| 69 | } | ||
| 70 | |||
| 71 | /** | ||
| 72 | * TerminalManagerShape - Service API for terminal session lifecycle operations. | ||
| 73 | */ | ||
| 74 | export interface TerminalManagerShape { | ||
| 75 | /** | ||
| 76 | * Open or attach to a terminal session. | ||
| 77 | * | ||
| 78 | * Reuses an existing session for the same thread/terminal id and restores | ||
| 79 | * persisted history on first open. | ||
| 80 | */ | ||
| 81 | readonly open: ( | ||
| 82 | input: TerminalOpenInput, | ||
| 83 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | ||
| 84 | |||
| 85 | /** | ||
| 86 | * Attach to a terminal and stream its initial snapshot followed by live events. | ||
| 87 | * | ||
| 88 | * Returns an unsubscribe function. | ||
| 89 | */ | ||
| 90 | readonly attachStream: ( | ||
| 91 | input: TerminalAttachInput, | ||
| 92 | listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>, | ||
| 93 | ) => Effect.Effect<() => void, TerminalError>; | ||
| 94 | |||
| 95 | /** | ||
| 96 | * Write input bytes to a terminal session. | ||
| 97 | */ | ||
| 98 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>; | ||
| 99 | |||
| 100 | /** | ||
| 101 | * Resize the PTY backing a terminal session. | ||
| 102 | */ | ||
| 103 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>; | ||
| 104 | |||
| 105 | /** | ||
| 106 | * Clear terminal output history. | ||
| 107 | */ | ||
| 108 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>; | ||
| 109 | |||
| 110 | /** | ||
| 111 | * Restart a terminal session in place. | ||
| 112 | * | ||
| 113 | * Always resets history before spawning the new process. | ||
| 114 | */ | ||
| 115 | readonly restart: ( | ||
| 116 | input: TerminalRestartInput, | ||
| 117 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | ||
| 118 | |||
| 119 | /** | ||
| 120 | * Close an active terminal session. | ||
| 121 | * | ||
| 122 | * When `terminalId` is omitted, closes all sessions for the thread. | ||
| 123 | */ | ||
| 124 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>; | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Subscribe to terminal runtime events with a direct callback. | ||
| 128 | * | ||
| 129 | * Returns an unsubscribe function. | ||
| 130 | */ | ||
| 131 | readonly subscribe: ( | ||
| 132 | listener: (event: TerminalEvent) => Effect.Effect<void>, | ||
| 133 | ) => Effect.Effect<() => void>; | ||
| 134 | |||
| 135 | /** | ||
| 136 | * Subscribe to lightweight terminal metadata with an initial full snapshot. | ||
| 137 | * | ||
| 138 | * Returns an unsubscribe function. | ||
| 139 | */ | ||
| 140 | readonly subscribeMetadata: ( | ||
| 141 | listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>, | ||
| 142 | ) => Effect.Effect<() => void>; | ||
| 143 | } | ||
| 144 | |||
| 145 | /** | ||
| 146 | * TerminalManager - Service tag for terminal session orchestration. | ||
| 147 | */ | ||
| 148 | export class TerminalManager extends Context.Service<TerminalManager, TerminalManagerShape>()( | ||
| 149 | "t3/terminal/Services/Manager/TerminalManager", | ||
| 150 | ) {} | ||
apps/server/src/terminal/Services/PTY.ts deleted-60| ... | @@ -1,60 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * PtyAdapter - Terminal PTY adapter service contract. | ||
| 3 | * | ||
| 4 | * Defines the process primitives required by terminal session management | ||
| 5 | * without binding to a specific PTY implementation. | ||
| 6 | * | ||
| 7 | * @module PtyAdapter | ||
| 8 | */ | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | |||
| 13 | /** | ||
| 14 | * PtyError - Error type for PTY adapter operations. | ||
| 15 | */ | ||
| 16 | export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", { | ||
| 17 | adapter: Schema.String, | ||
| 18 | message: Schema.String, | ||
| 19 | cause: Schema.optional(Schema.Defect()), | ||
| 20 | }) {} | ||
| 21 | |||
| 22 | export interface PtyExitEvent { | ||
| 23 | exitCode: number; | ||
| 24 | signal: number | null; | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface PtyProcess { | ||
| 28 | readonly pid: number; | ||
| 29 | write(data: string): void; | ||
| 30 | resize(cols: number, rows: number): void; | ||
| 31 | kill(signal?: string): void; | ||
| 32 | onData(callback: (data: string) => void): () => void; | ||
| 33 | onExit(callback: (event: PtyExitEvent) => void): () => void; | ||
| 34 | } | ||
| 35 | |||
| 36 | export interface PtySpawnInput { | ||
| 37 | shell: string; | ||
| 38 | args?: string[]; | ||
| 39 | cwd: string; | ||
| 40 | cols: number; | ||
| 41 | rows: number; | ||
| 42 | env: NodeJS.ProcessEnv; | ||
| 43 | } | ||
| 44 | |||
| 45 | /** | ||
| 46 | * PtyAdapterShape - Service API for spawning and controlling PTY processes. | ||
| 47 | */ | ||
| 48 | export interface PtyAdapterShape { | ||
| 49 | /** | ||
| 50 | * Spawn a PTY process for a terminal session. | ||
| 51 | */ | ||
| 52 | spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError>; | ||
| 53 | } | ||
| 54 | |||
| 55 | /** | ||
| 56 | * PtyAdapter - Service tag for PTY process integration. | ||
| 57 | */ | ||
| 58 | export class PtyAdapter extends Context.Service<PtyAdapter, PtyAdapterShape>()( | ||
| 59 | "t3/terminal/Services/PTY/PtyAdapter", | ||
| 60 | ) {} | ||
apps/server/src/ws.ts+53-46| ... | @@ -56,45 +56,44 @@ import { clamp } from "effect/Number"; | ... | @@ -56,45 +56,44 @@ import { clamp } from "effect/Number"; |
| 56 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; | 56 | import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; |
| 57 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | 57 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 58 | 58 | ||
| 59 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; | 59 | import * as CheckpointDiffQuery from "./checkpointing/Services/CheckpointDiffQuery.ts"; |
| 60 | import { ServerConfig } from "./config.ts"; | 60 | import * as ServerConfig from "./config.ts"; |
| 61 | import { Keybindings } from "./keybindings.ts"; | 61 | import * as Keybindings from "./keybindings.ts"; |
| 62 | import * as ExternalLauncher from "./process/externalLauncher.ts"; | 62 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 63 | import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; | 63 | import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; |
| 64 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; | 64 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 65 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 65 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 66 | import { | 66 | import { |
| 67 | observeRpcEffect as instrumentRpcEffect, | 67 | observeRpcEffect as instrumentRpcEffect, |
| 68 | observeRpcStream as instrumentRpcStream, | 68 | observeRpcStream as instrumentRpcStream, |
| 69 | observeRpcStreamEffect as instrumentRpcStreamEffect, | 69 | observeRpcStreamEffect as instrumentRpcStreamEffect, |
| 70 | } from "./observability/RpcInstrumentation.ts"; | 70 | } from "./observability/RpcInstrumentation.ts"; |
| 71 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; | 71 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 72 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | 72 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| 73 | import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; | 73 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 74 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; | 74 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 75 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; | 75 | import * as ServerSettings from "./serverSettings.ts"; |
| 76 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 76 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 77 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | 77 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 78 | import * as PreviewManager from "./preview/Manager.ts"; | 78 | import * as PreviewManager from "./preview/Manager.ts"; |
| 79 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; | 79 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 80 | import * as PortScanner from "./preview/PortScanner.ts"; | 80 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 81 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 81 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 82 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 82 | import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 83 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; | 83 | import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.ts"; |
| 84 | import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; | 84 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 85 | import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; | 85 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 86 | import { GitWorkflowService } from "./git/GitWorkflowService.ts"; | 86 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 87 | import { ReviewService } from "./review/ReviewService.ts"; | 87 | import * as ReviewService from "./review/ReviewService.ts"; |
| 88 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; | 88 | import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts"; |
| 89 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; | 89 | import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; |
| 90 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | 90 | import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; |
| 91 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 91 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 92 | import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; | ||
| 93 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 92 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 94 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 93 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 95 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 94 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 96 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; | 95 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; |
| 97 | import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; | 96 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 98 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | 97 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 99 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | 98 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 100 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | 99 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| ... | @@ -109,7 +108,7 @@ import * as SessionStore from "./auth/SessionStore.ts"; | ... | @@ -109,7 +108,7 @@ import * as SessionStore from "./auth/SessionStore.ts"; |
| 109 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; | 108 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; |
| 110 | import * as RelayClient from "@t3tools/shared/relayClient"; | 109 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 111 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); | 110 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 112 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); | 111 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); |
| 113 | 112 | ||
| 114 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 113 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 115 | 114 | ||
| ... | @@ -248,35 +247,36 @@ function toAuthAccessStreamEvent( | ... | @@ -248,35 +247,36 @@ function toAuthAccessStreamEvent( |
| 248 | } | 247 | } |
| 249 | } | 248 | } |
| 250 | 249 | ||
| 251 | const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | 250 | const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 252 | WsRpcGroup.toLayer( | 251 | WsRpcGroup.toLayer( |
| 253 | Effect.gen(function* () { | 252 | Effect.gen(function* () { |
| 254 | const currentSessionId = currentSession.sessionId; | 253 | const currentSessionId = currentSession.sessionId; |
| 255 | const crypto = yield* Crypto.Crypto; | 254 | const crypto = yield* Crypto.Crypto; |
| 256 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 255 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 257 | const orchestrationEngine = yield* OrchestrationEngineService; | 256 | const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; |
| 258 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | 257 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 259 | const keybindings = yield* Keybindings; | 258 | const keybindings = yield* Keybindings.Keybindings; |
| 260 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; | 259 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 261 | const gitWorkflow = yield* GitWorkflowService; | 260 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; |
| 262 | const review = yield* ReviewService; | 261 | const review = yield* ReviewService.ReviewService; |
| 263 | const vcsProvisioning = yield* VcsProvisioningService; | 262 | const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; |
| 264 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 263 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 265 | const terminalManager = yield* TerminalManager; | 264 | const terminalManager = yield* TerminalManager.TerminalManager; |
| 266 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | 265 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 267 | const previewManager = yield* PreviewManager.PreviewManager; | 266 | const previewManager = yield* PreviewManager.PreviewManager; |
| 268 | const portDiscovery = yield* PortScanner.PortDiscovery; | 267 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 269 | const providerRegistry = yield* ProviderRegistry; | 268 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| 270 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | 269 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 271 | const config = yield* ServerConfig; | 270 | const config = yield* ServerConfig.ServerConfig; |
| 272 | const lifecycleEvents = yield* ServerLifecycleEvents; | 271 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| 273 | const serverSettings = yield* ServerSettingsService; | 272 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 274 | const startup = yield* ServerRuntimeStartup; | 273 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; |
| 275 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | 274 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 276 | const workspaceFileSystem = yield* WorkspaceFileSystem; | 275 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; |
| 277 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | 276 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 278 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | 277 | const repositoryIdentityResolver = |
| 279 | const serverEnvironment = yield* ServerEnvironment; | 278 | yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 279 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| 280 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 280 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 281 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; | 281 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; |
| 282 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( | 282 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( |
| ... | @@ -287,7 +287,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -287,7 +287,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 287 | }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), | 287 | }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), |
| 288 | ), | 288 | ), |
| 289 | ); | 289 | ); |
| 290 | const sourceControlRepositories = yield* SourceControlRepositoryService; | 290 | const sourceControlRepositories = |
| 291 | yield* SourceControlRepositoryService.SourceControlRepositoryService; | ||
| 291 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 292 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 292 | const sessions = yield* SessionStore.SessionStore; | 293 | const sessions = yield* SessionStore.SessionStore; |
| 293 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; | 294 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| ... | @@ -766,7 +767,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -766,7 +767,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 766 | const loadServerConfig = Effect.gen(function* () { | 767 | const loadServerConfig = Effect.gen(function* () { |
| 767 | const keybindingsConfig = yield* keybindings.loadConfigState; | 768 | const keybindingsConfig = yield* keybindings.loadConfigState; |
| 768 | const providers = yield* providerRegistry.getProviders; | 769 | const providers = yield* providerRegistry.getProviders; |
| 769 | const settings = redactServerSettingsForClient(yield* serverSettings.getSettings); | 770 | const settings = ServerSettings.redactServerSettingsForClient( |
| 771 | yield* serverSettings.getSettings, | ||
| 772 | ); | ||
| 770 | const environment = yield* serverEnvironment.getDescriptor; | 773 | const environment = yield* serverEnvironment.getDescriptor; |
| 771 | const auth = yield* serverAuth.getDescriptor(); | 774 | const auth = yield* serverAuth.getDescriptor(); |
| 772 | 775 | ||
| ... | @@ -1068,7 +1071,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1068,7 +1071,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1068 | [WS_METHODS.serverGetSettings]: (_input) => | 1071 | [WS_METHODS.serverGetSettings]: (_input) => |
| 1069 | observeRpcEffect( | 1072 | observeRpcEffect( |
| 1070 | WS_METHODS.serverGetSettings, | 1073 | WS_METHODS.serverGetSettings, |
| 1071 | serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)), | 1074 | serverSettings.getSettings.pipe( |
| 1075 | Effect.map(ServerSettings.redactServerSettingsForClient), | ||
| 1076 | ), | ||
| 1072 | { | 1077 | { |
| 1073 | "rpc.aggregate": "server", | 1078 | "rpc.aggregate": "server", |
| 1074 | }, | 1079 | }, |
| ... | @@ -1076,7 +1081,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1076,7 +1081,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1076 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => | 1081 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => |
| 1077 | observeRpcEffect( | 1082 | observeRpcEffect( |
| 1078 | WS_METHODS.serverUpdateSettings, | 1083 | WS_METHODS.serverUpdateSettings, |
| 1079 | serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)), | 1084 | serverSettings |
| 1085 | .updateSettings(patch) | ||
| 1086 | .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), | ||
| 1080 | { | 1087 | { |
| 1081 | "rpc.aggregate": "server", | 1088 | "rpc.aggregate": "server", |
| 1082 | }, | 1089 | }, |
| ... | @@ -1559,7 +1566,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1559,7 +1566,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1559 | Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), | 1566 | Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), |
| 1560 | ); | 1567 | ); |
| 1561 | const settingsUpdates = serverSettings.streamChanges.pipe( | 1568 | const settingsUpdates = serverSettings.streamChanges.pipe( |
| 1562 | Stream.map((settings) => redactServerSettingsForClient(settings)), | 1569 | Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), |
| 1563 | Stream.map((settings) => ({ | 1570 | Stream.map((settings) => ({ |
| 1564 | version: 1 as const, | 1571 | version: 1 as const, |
| 1565 | type: "settingsUpdated" as const, | 1572 | type: "settingsUpdated" as const, |