| author | |
| committer | |
| log | 470f282819079be867cdc1eaa52b0eeb632c14e7 |
| tree | 59847f8a9a68bba2c6bd18bfa6c44ab9cf85c794 |
| parent | 3fbd02c6cfc667dbf239cb325f00caa2e4ac662c |
11 files changed, 398 insertions(+), 319 deletions(-)
.plans/terminal-runtime-state-plan.md deleted-154| ... | ... | @@ -1,154 +0,0 @@ |
| 1 | # Terminal Runtime And State Plan | |
| 2 | ||
| 3 | ## Summary | |
| 4 | ||
| 5 | Terminals appear to "hang" because PTY processes are exiting immediately at startup in the current runtime. | |
| 6 | ||
| 7 | Repro in this branch: | |
| 8 | ||
| 9 | - `node` + `node-pty`: interactive shell stays alive and accepts writes. | |
| 10 | - `bun` + `node-pty`: shell exits immediately (`exitCode: 0`, `signal: 1`), so the UI opens a terminal pane but no live process remains. | |
| 11 | ||
| 12 | This means the current breakage is primarily a runtime compatibility issue, plus missing reconciliation between desired terminal state and observed process state. | |
| 13 | ||
| 14 | ## Root Cause (Current) | |
| 15 | ||
| 16 | 1. Server dev runtime uses Bun (`apps/server/package.json` `dev` script). | |
| 17 | 2. Terminal PTY implementation depends on `node-pty`. | |
| 18 | 3. Under Bun, spawned PTY shells exit immediately. | |
| 19 | 4. Frontend still mounts terminals, so UX looks like startup hang/blank terminal. | |
| 20 | ||
| 21 | ## Design Goals | |
| 22 | ||
| 23 | 1. Do not store full terminal output in SQLite. | |
| 24 | 2. Preserve terminal topology and intent across reconnects/restarts: | |
| 25 | - open/split/new/close | |
| 26 | - active terminal/group | |
| 27 | - cwd/env profile | |
| 28 | 3. Keep runtime truth accurate even when processes change outside the app. | |
| 29 | 4. Support deterministic recovery and idempotent command handling. | |
| 30 | ||
| 31 | ## Proposed Architecture | |
| 32 | ||
| 33 | ### 1) Runtime Boundary | |
| 34 | ||
| 35 | - Run terminal PTY host on Node runtime only. | |
| 36 | - If main server remains Bun, isolate PTY behind a Node sidecar process (JSON-RPC/IPC). | |
| 37 | - Treat PTY host as volatile runtime, not source of durable truth. | |
| 38 | ||
| 39 | ### 2) Event-Sourced In SQLite (Control Plane Only) | |
| 40 | ||
| 41 | Persist only control/state events, not output payloads. | |
| 42 | ||
| 43 | Recommended event families: | |
| 44 | ||
| 45 | - `terminal.layout.updated` | |
| 46 | - terminal ids, split groups, active terminal id/group id | |
| 47 | - `terminal.open.requested` | |
| 48 | - threadId, terminalId, cwd, envHash/envProfileId, cols, rows | |
| 49 | - `terminal.close.requested` | |
| 50 | - terminalId, deleteHistory | |
| 51 | - `terminal.resize.requested` | |
| 52 | - cols, rows | |
| 53 | - `terminal.clear.requested` | |
| 54 | - `terminal.input.sent` | |
| 55 | - input metadata only (`byteLength`, optional hash, optional commandBoundary flag), no raw payload | |
| 56 | - `terminal.runtime.started` | |
| 57 | - pid, startedAt, runtime instance id | |
| 58 | - `terminal.runtime.exited` | |
| 59 | - pid, exitCode, exitSignal, observedAt, reason (`normal|killed|external|crash|unknown`) | |
| 60 | - `terminal.runtime.error` | |
| 61 | - normalized error code/message | |
| 62 | - `terminal.output.segment.recorded` | |
| 63 | - segment file id + byte offsets only | |
| 64 | ||
| 65 | Use idempotency keys/command ids for each client command so reconnect retries are safe. | |
| 66 | ||
| 67 | ### 3) Output Storage In Log Files (Data Plane) | |
| 68 | ||
| 69 | - Store raw output only in append-only files (per thread+terminal). | |
| 70 | - Rotate into segments (size-based and/or time-based). | |
| 71 | - Keep lightweight SQLite index rows: | |
| 72 | - `segment_id`, `thread_id`, `terminal_id`, `start_offset`, `end_offset`, `created_at`, checksum. | |
| 73 | - Optional retention policy (max bytes / days / segment count per terminal). | |
| 74 | ||
| 75 | This keeps SQLite small while preserving replay capability. | |
| 76 | ||
| 77 | ### 4) Reconciliation Loop (Runtime Truth) | |
| 78 | ||
| 79 | Do not treat client dispatch as sole truth. | |
| 80 | ||
| 81 | Maintain dual state: | |
| 82 | ||
| 83 | - Desired state: from event stream/projection. | |
| 84 | - Observed runtime state: from PTY host watchers (process exit, health checks, poll). | |
| 85 | ||
| 86 | On mismatch, emit runtime observation events: | |
| 87 | ||
| 88 | - process missing but desired running -> `terminal.runtime.exited` with reason `external|unknown` | |
| 89 | - process exists but projection says closed -> terminate process and emit correction event | |
| 90 | ||
| 91 | This makes out-of-band kills/restarts explicit and recoverable. | |
| 92 | ||
| 93 | ### 5) Startup/Recovery Flow | |
| 94 | ||
| 95 | 1. Rebuild terminal projection from SQLite control events. | |
| 96 | 2. Load terminal layout/topology immediately for UI. | |
| 97 | 3. Reconcile each desired running terminal with runtime: | |
| 98 | - if process alive: attach stream + tail recent output from logs | |
| 99 | - if process dead: mark exited; auto-restart only if policy says so | |
| 100 | 4. Resume incremental output from last persisted offset. | |
| 101 | ||
| 102 | ## Data Model Additions | |
| 103 | ||
| 104 | Add dedicated tables (or equivalent repos): | |
| 105 | ||
| 106 | - `terminal_control_events` (or reuse orchestration_events with terminal aggregate kind) | |
| 107 | - `terminal_output_segments` (index only, no raw output) | |
| 108 | - `terminal_runtime_snapshot` (latest observed runtime state cache) | |
| 109 | ||
| 110 | Avoid embedding output blobs in projection snapshots. | |
| 111 | ||
| 112 | ## Implementation Plan | |
| 113 | ||
| 114 | ### Phase 0: Stop the bleeding | |
| 115 | ||
| 116 | 1. Run PTY on Node runtime in all dev/prod paths (or ship Node sidecar now). | |
| 117 | 2. Add startup diagnostics: runtime, PTY spawn result, immediate-exit telemetry. | |
| 118 | 3. Surface clear UI error when PTY host unavailable. | |
| 119 | ||
| 120 | ### Phase 1: Durable control events | |
| 121 | ||
| 122 | 1. Introduce terminal aggregate + commands/events. | |
| 123 | 2. Persist layout and terminal lifecycle intent events. | |
| 124 | 3. Build projection for terminal UI state from events. | |
| 125 | ||
| 126 | ### Phase 2: File-backed output + index | |
| 127 | ||
| 128 | 1. Implement segmented append-only output logger. | |
| 129 | 2. Persist only segment metadata/offsets in SQLite. | |
| 130 | 3. Update terminal history restore to tail from segments (bounded bytes/lines). | |
| 131 | ||
| 132 | ### Phase 3: Runtime reconciliation | |
| 133 | ||
| 134 | 1. Add process observer + periodic verifier. | |
| 135 | 2. Emit runtime observation events on drift. | |
| 136 | 3. Add recovery tests for out-of-band process termination. | |
| 137 | ||
| 138 | ### Phase 4: Hardening | |
| 139 | ||
| 140 | 1. Backpressure and truncation policies for high-volume output. | |
| 141 | 2. Crash-safe fsync/flush strategy for segment writes. | |
| 142 | 3. Retention and compaction jobs. | |
| 143 | ||
| 144 | ## Testing Strategy | |
| 145 | ||
| 146 | Backend tests should cover: | |
| 147 | ||
| 148 | 1. Rebuild projection from control events (open/split/close/resize/active terminal). | |
| 149 | 2. Runtime reconciliation when process is killed externally. | |
| 150 | 3. Recovery after restart with existing segment files. | |
| 151 | 4. Idempotent command handling on duplicate dispatches. | |
| 152 | 5. Segment index consistency and retention behavior. | |
| 153 | ||
| 154 | Use real runtime-layer tests for terminal adapter behavior where possible; only mock external process/service boundaries. |
apps/server/package.json+1| ... | ... | @@ -31,6 +31,7 @@ |
| 31 | 31 | "@effect/vitest": "4.0.0-beta.10", |
| 32 | 32 | "@t3tools/contracts": "workspace:*", |
| 33 | 33 | "@t3tools/web": "workspace:*", |
| 34 | "@types/bun": "^1.3.9", | |
| 34 | 35 | "@types/node": "^22.10.2", |
| 35 | 36 | "@types/ws": "^8.5.13", |
| 36 | 37 | "tsdown": "^0.20.3", |
apps/server/src/ptyAdapter.test.ts+43-32| ... | ... | @@ -2,7 +2,9 @@ import fs from "node:fs"; |
| 2 | 2 | import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 5 | import { assert, it, vi } from "@effect/vitest"; | |
| 6 | import { Effect, Layer } from "effect"; | |
| 7 | import { afterEach } from "vitest"; | |
| 6 | 8 | |
| 7 | 9 | import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; |
| 8 | 10 | |
| ... | ... | @@ -10,45 +12,54 @@ function fileMode(filePath: string): number { |
| 10 | 12 | return fs.statSync(filePath).mode & 0o777; |
| 11 | 13 | } |
| 12 | 14 | |
| 13 | describe("ensureNodePtySpawnHelperExecutable", () => { | |
| 14 | const tempDirs: string[] = []; | |
| 15 | const tempDirs: string[] = []; | |
| 15 | 16 | |
| 16 | afterEach(() => { | |
| 17 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 18 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 19 | } | |
| 20 | }); | |
| 17 | afterEach(() => { | |
| 18 | vi.unstubAllGlobals(); | |
| 19 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 20 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 21 | } | |
| 22 | }); | |
| 23 | ||
| 24 | const helperLayer = it.layer(Layer.empty); | |
| 21 | 25 | |
| 22 | it("adds executable bits when helper exists but is not executable", () => { | |
| 23 | if (process.platform === "win32") return; | |
| 26 | helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { | |
| 27 | it.effect("adds executable bits when helper exists but is not executable", () => | |
| 28 | Effect.sync(() => { | |
| 29 | if (process.platform === "win32") return; | |
| 24 | 30 | |
| 25 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 26 | tempDirs.push(dir); | |
| 27 | const helperPath = path.join(dir, "spawn-helper"); | |
| 28 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 29 | fs.chmodSync(helperPath, 0o644); | |
| 31 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 32 | tempDirs.push(dir); | |
| 33 | const helperPath = path.join(dir, "spawn-helper"); | |
| 34 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 35 | fs.chmodSync(helperPath, 0o644); | |
| 30 | 36 | |
| 31 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 37 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 32 | 38 | |
| 33 | expect(fileMode(helperPath) & 0o111).toBe(0o111); | |
| 34 | }); | |
| 39 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | |
| 40 | }), | |
| 41 | ); | |
| 35 | 42 | |
| 36 | it("keeps executable helper as executable", () => { | |
| 37 | if (process.platform === "win32") return; | |
| 43 | it.effect("keeps executable helper as executable", () => | |
| 44 | Effect.sync(() => { | |
| 45 | if (process.platform === "win32") return; | |
| 38 | 46 | |
| 39 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 40 | tempDirs.push(dir); | |
| 41 | const helperPath = path.join(dir, "spawn-helper"); | |
| 42 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 43 | fs.chmodSync(helperPath, 0o755); | |
| 47 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 48 | tempDirs.push(dir); | |
| 49 | const helperPath = path.join(dir, "spawn-helper"); | |
| 50 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 51 | fs.chmodSync(helperPath, 0o755); | |
| 44 | 52 | |
| 45 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 53 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 46 | 54 | |
| 47 | expect(fileMode(helperPath) & 0o111).toBe(0o111); | |
| 48 | }); | |
| 55 | assert.equal(fileMode(helperPath) & 0o111, 0o111); | |
| 56 | }), | |
| 57 | ); | |
| 49 | 58 | |
| 50 | it("does not throw when helper path is missing", () => { | |
| 51 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); | |
| 52 | expect(() => ensureNodePtySpawnHelperExecutable(missing)).not.toThrow(); | |
| 53 | }); | |
| 59 | it.effect("does not throw when helper path is missing", () => | |
| 60 | Effect.sync(() => { | |
| 61 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); | |
| 62 | ensureNodePtySpawnHelperExecutable(missing); | |
| 63 | }), | |
| 64 | ); | |
| 54 | 65 | }); |
apps/server/src/ptyAdapter.ts+139-14| ... | ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; |
| 2 | 2 | import { createRequire } from "node:module"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | import * as nodePty from "node-pty"; | |
| 5 | import { Effect, Layer, ServiceMap } from "effect"; | |
| 6 | 6 | |
| 7 | 7 | export interface PtyExitEvent { |
| 8 | 8 | exitCode: number; |
| ... | ... | @@ -27,10 +27,17 @@ export interface PtySpawnInput { |
| 27 | 27 | env: NodeJS.ProcessEnv; |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | export interface PtyAdapter { | |
| 30 | export interface PtyAdapterShape { | |
| 31 | 31 | spawn(input: PtySpawnInput): PtyProcess; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()( | |
| 35 | "terminal/PtyAdapter", | |
| 36 | ) { | |
| 37 | static readonly layer = () => | |
| 38 | typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive; | |
| 39 | } | |
| 40 | ||
| 34 | 41 | const requireForNodePty = createRequire(import.meta.url); |
| 35 | 42 | let didEnsureSpawnHelperExecutable = false; |
| 36 | 43 | |
| ... | ... | @@ -78,7 +85,7 @@ export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void |
| 78 | 85 | } |
| 79 | 86 | |
| 80 | 87 | class NodePtyProcess implements PtyProcess { |
| 81 | constructor(private readonly process: nodePty.IPty) {} | |
| 88 | constructor(private readonly process: import("node-pty").IPty) {} | |
| 82 | 89 | |
| 83 | 90 | get pid(): number { |
| 84 | 91 | return this.process.pid; |
| ... | ... | @@ -116,16 +123,134 @@ class NodePtyProcess implements PtyProcess { |
| 116 | 123 | } |
| 117 | 124 | } |
| 118 | 125 | |
| 119 | export class NodePtyAdapter implements PtyAdapter { | |
| 120 | spawn(input: PtySpawnInput): PtyProcess { | |
| 121 | ensureNodePtySpawnHelperExecutable(); | |
| 122 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 123 | cwd: input.cwd, | |
| 124 | cols: input.cols, | |
| 125 | rows: input.rows, | |
| 126 | env: input.env, | |
| 127 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 128 | }); | |
| 129 | return new NodePtyProcess(ptyProcess); | |
| 126 | class BunPtyProcess implements PtyProcess { | |
| 127 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 128 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 129 | private readonly decoder = new TextDecoder(); | |
| 130 | private didExit = false; | |
| 131 | ||
| 132 | constructor(private readonly process: Bun.Subprocess) { | |
| 133 | void this.process.exited | |
| 134 | .then((exitCode) => { | |
| 135 | this.emitExit({ | |
| 136 | exitCode: Number.isInteger(exitCode) ? exitCode : 0, | |
| 137 | signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, | |
| 138 | }); | |
| 139 | }) | |
| 140 | .catch(() => { | |
| 141 | this.emitExit({ exitCode: 1, signal: null }); | |
| 142 | }); | |
| 143 | } | |
| 144 | ||
| 145 | get pid(): number { | |
| 146 | return this.process.pid; | |
| 147 | } | |
| 148 | ||
| 149 | write(data: string): void { | |
| 150 | if (!this.process.terminal) { | |
| 151 | throw new Error("Bun PTY terminal handle is unavailable"); | |
| 152 | } | |
| 153 | this.process.terminal.write(data); | |
| 154 | } | |
| 155 | ||
| 156 | resize(cols: number, rows: number): void { | |
| 157 | if (!this.process.terminal?.resize) { | |
| 158 | throw new Error("Bun PTY resize is unavailable"); | |
| 159 | } | |
| 160 | this.process.terminal.resize(cols, rows); | |
| 161 | } | |
| 162 | ||
| 163 | kill(signal?: string): void { | |
| 164 | if (!signal) { | |
| 165 | this.process.kill(); | |
| 166 | return; | |
| 167 | } | |
| 168 | this.process.kill(signal as NodeJS.Signals); | |
| 169 | } | |
| 170 | ||
| 171 | onData(callback: (data: string) => void): () => void { | |
| 172 | this.dataListeners.add(callback); | |
| 173 | return () => { | |
| 174 | this.dataListeners.delete(callback); | |
| 175 | }; | |
| 176 | } | |
| 177 | ||
| 178 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 179 | this.exitListeners.add(callback); | |
| 180 | return () => { | |
| 181 | this.exitListeners.delete(callback); | |
| 182 | }; | |
| 183 | } | |
| 184 | ||
| 185 | emitData(data: Uint8Array): void { | |
| 186 | if (this.didExit) return; | |
| 187 | const text = this.decoder.decode(data, { stream: true }); | |
| 188 | if (text.length === 0) return; | |
| 189 | for (const listener of this.dataListeners) { | |
| 190 | listener(text); | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | private emitExit(event: PtyExitEvent): void { | |
| 195 | if (this.didExit) return; | |
| 196 | this.didExit = true; | |
| 197 | ||
| 198 | const remainder = this.decoder.decode(); | |
| 199 | if (remainder.length > 0) { | |
| 200 | for (const listener of this.dataListeners) { | |
| 201 | listener(remainder); | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | for (const listener of this.exitListeners) { | |
| 206 | listener(event); | |
| 207 | } | |
| 130 | 208 | } |
| 131 | 209 | } |
| 210 | export const NodePtyAdapterLive = Layer.effect( | |
| 211 | PtyAdapter, | |
| 212 | Effect.gen(function* () { | |
| 213 | const nodePty = yield* Effect.promise(() => import("node-pty")); | |
| 214 | return { | |
| 215 | spawn(input: PtySpawnInput): PtyProcess { | |
| 216 | ensureNodePtySpawnHelperExecutable(); | |
| 217 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 218 | cwd: input.cwd, | |
| 219 | cols: input.cols, | |
| 220 | rows: input.rows, | |
| 221 | env: input.env, | |
| 222 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 223 | }); | |
| 224 | return new NodePtyProcess(ptyProcess); | |
| 225 | }, | |
| 226 | }; | |
| 227 | }), | |
| 228 | ); | |
| 229 | ||
| 230 | export const BunPtyAdapterLive = Layer.effect( | |
| 231 | PtyAdapter, | |
| 232 | Effect.gen(function* () { | |
| 233 | if (process.platform === "win32") { | |
| 234 | return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); | |
| 235 | } | |
| 236 | return { | |
| 237 | spawn(input: PtySpawnInput): PtyProcess { | |
| 238 | let processHandle: BunPtyProcess | null = null; | |
| 239 | const command = [input.shell, ...(input.args ?? [])]; | |
| 240 | const subprocess = Bun.spawn(command, { | |
| 241 | cwd: input.cwd, | |
| 242 | env: input.env, | |
| 243 | terminal: { | |
| 244 | cols: input.cols, | |
| 245 | rows: input.rows, | |
| 246 | data: (_terminal, data) => { | |
| 247 | processHandle?.emitData(data); | |
| 248 | }, | |
| 249 | }, | |
| 250 | }); | |
| 251 | processHandle = new BunPtyProcess(subprocess); | |
| 252 | return processHandle; | |
| 253 | }, | |
| 254 | }; | |
| 255 | }), | |
| 256 | ); |
apps/server/src/serverLayers.ts+17-3| ... | ... | @@ -26,6 +26,9 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 26 | 26 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 27 | 27 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 28 | 28 | |
| 29 | import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; | |
| 30 | import { PtyAdapter } from "./ptyAdapter"; | |
| 31 | ||
| 29 | 32 | export function makeServerProviderLayer(): Layer.Layer< |
| 30 | 33 | ProviderService, |
| 31 | 34 | unknown, |
| ... | ... | @@ -48,9 +51,13 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 48 | 51 | } |
| 49 | 52 | |
| 50 | 53 | export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 51 | OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor, | |
| 54 | | OrchestrationEngineService | |
| 55 | | ProjectionSnapshotQuery | |
| 56 | | CheckpointStore | |
| 57 | | OrchestrationReactor | |
| 58 | | TerminalManager, | |
| 52 | 59 | unknown, |
| 53 | SqlClient.SqlClient | ProviderService | |
| 60 | SqlClient.SqlClient | ProviderService | ServerConfig | |
| 54 | 61 | > { |
| 55 | 62 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 56 | 63 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | ... | @@ -79,5 +86,12 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 79 | 86 | Layer.provideMerge(checkpointReactorLayer), |
| 80 | 87 | ); |
| 81 | 88 | |
| 82 | return orchestrationReactorLayer; | |
| 89 | const terminalLayer = Effect.gen(function* () { | |
| 90 | const { stateDir } = yield* ServerConfig; | |
| 91 | return makeTerminalManagerLive({ | |
| 92 | logsDir: path.join(stateDir, "logs", "terminals"), | |
| 93 | }).pipe(Layer.provide(PtyAdapter.layer())); | |
| 94 | }).pipe(Layer.unwrap); | |
| 95 | ||
| 96 | return Layer.merge(orchestrationReactorLayer, terminalLayer); | |
| 83 | 97 | } |
apps/server/src/terminalManager.test.ts+4-4| ... | ... | @@ -9,8 +9,8 @@ import { |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { afterEach, describe, expect, it } from "vitest"; |
| 11 | 11 | |
| 12 | import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; | |
| 13 | import { TerminalManager } from "./terminalManager"; | |
| 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; | |
| 13 | import { TerminalManagerRuntime } from "./terminalManager"; | |
| 14 | 14 | |
| 15 | 15 | class FakePtyProcess implements PtyProcess { |
| 16 | 16 | readonly writes: string[] = []; |
| ... | ... | @@ -60,7 +60,7 @@ class FakePtyProcess implements PtyProcess { |
| 60 | 60 | } |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | class FakePtyAdapter implements PtyAdapter { | |
| 63 | class FakePtyAdapter implements PtyAdapterShape { | |
| 64 | 64 | readonly spawnInputs: PtySpawnInput[] = []; |
| 65 | 65 | readonly processes: FakePtyProcess[] = []; |
| 66 | 66 | readonly spawnFailures: Error[] = []; |
| ... | ... | @@ -150,7 +150,7 @@ describe("TerminalManager", () => { |
| 150 | 150 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); |
| 151 | 151 | tempDirs.push(logsDir); |
| 152 | 152 | const ptyAdapter = new FakePtyAdapter(); |
| 153 | const manager = new TerminalManager({ | |
| 153 | const manager = new TerminalManagerRuntime({ | |
| 154 | 154 | logsDir, |
| 155 | 155 | ptyAdapter, |
| 156 | 156 | historyLineLimit, |
apps/server/src/terminalManager.ts+57-10| ... | ... | @@ -13,10 +13,10 @@ import { |
| 13 | 13 | type TerminalSessionSnapshot, |
| 14 | 14 | type TerminalSessionStatus, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { Schema } from "effect"; | |
| 16 | import { Effect, Layer, Schema, ServiceMap } from "effect"; | |
| 17 | 17 | |
| 18 | 18 | import { createLogger } from "./logger"; |
| 19 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; | |
| 19 | import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; | |
| 20 | 20 | import { runProcess } from "./processRunner"; |
| 21 | 21 | |
| 22 | 22 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| ... | ... | @@ -41,12 +41,27 @@ export interface TerminalManagerEvents { |
| 41 | 41 | export interface TerminalManagerOptions { |
| 42 | 42 | logsDir?: string; |
| 43 | 43 | historyLineLimit?: number; |
| 44 | ptyAdapter?: PtyAdapter; | |
| 44 | ptyAdapter: PtyAdapterShape; | |
| 45 | 45 | shellResolver?: () => string; |
| 46 | 46 | subprocessChecker?: TerminalSubprocessChecker; |
| 47 | 47 | subprocessPollIntervalMs?: number; |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | export interface TerminalManagerShape { | |
| 51 | readonly open: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | |
| 52 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, unknown>; | |
| 53 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, unknown>; | |
| 54 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, unknown>; | |
| 55 | readonly restart: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>; | |
| 56 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, unknown>; | |
| 57 | readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>; | |
| 58 | readonly dispose: () => Effect.Effect<void>; | |
| 59 | } | |
| 60 | ||
| 61 | export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()( | |
| 62 | "terminal/TerminalManager", | |
| 63 | ) {} | |
| 64 | ||
| 50 | 65 | interface TerminalSessionState { |
| 51 | 66 | threadId: string; |
| 52 | 67 | terminalId: string; |
| ... | ... | @@ -73,8 +88,8 @@ interface ShellCandidate { |
| 73 | 88 | |
| 74 | 89 | interface TerminalStartInput extends TerminalOpenInput { |
| 75 | 90 | cols: number; |
| 76 | rows: number | |
| 77 | }; | |
| 91 | rows: number; | |
| 92 | } | |
| 78 | 93 | |
| 79 | 94 | function defaultShellResolver(): string { |
| 80 | 95 | if (process.platform === "win32") { |
| ... | ... | @@ -300,14 +315,14 @@ function normalizedRuntimeEnv( |
| 300 | 315 | if (!env) return null; |
| 301 | 316 | const entries = Object.entries(env); |
| 302 | 317 | if (entries.length === 0) return null; |
| 303 | return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); | |
| 318 | return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); | |
| 304 | 319 | } |
| 305 | 320 | |
| 306 | export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | |
| 321 | export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { | |
| 307 | 322 | private readonly sessions = new Map<string, TerminalSessionState>(); |
| 308 | 323 | private readonly logsDir: string; |
| 309 | 324 | private readonly historyLineLimit: number; |
| 310 | private readonly ptyAdapter: PtyAdapter; | |
| 325 | private readonly ptyAdapter: PtyAdapterShape; | |
| 311 | 326 | private readonly shellResolver: () => string; |
| 312 | 327 | private readonly persistQueues = new Map<string, Promise<void>>(); |
| 313 | 328 | private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); |
| ... | ... | @@ -320,11 +335,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 320 | 335 | private subprocessPollInFlight = false; |
| 321 | 336 | private readonly logger = createLogger("terminal"); |
| 322 | 337 | |
| 323 | constructor(options: TerminalManagerOptions = {}) { | |
| 338 | constructor(options: TerminalManagerOptions) { | |
| 324 | 339 | super(); |
| 325 | 340 | this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); |
| 326 | 341 | this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; |
| 327 | this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); | |
| 342 | this.ptyAdapter = options.ptyAdapter; | |
| 328 | 343 | this.shellResolver = options.shellResolver ?? defaultShellResolver; |
| 329 | 344 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; |
| 330 | 345 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; |
| ... | ... | @@ -1046,3 +1061,35 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 1046 | 1061 | } |
| 1047 | 1062 | } |
| 1048 | 1063 | } |
| 1064 | ||
| 1065 | export function makeTerminalManagerLive( | |
| 1066 | options: Omit<TerminalManagerOptions, "ptyAdapter"> = {}, | |
| 1067 | ): Layer.Layer<TerminalManager, never, PtyAdapter> { | |
| 1068 | return Layer.effect( | |
| 1069 | TerminalManager, | |
| 1070 | Effect.gen(function* () { | |
| 1071 | const ptyAdapter = yield* PtyAdapter; | |
| 1072 | const runtime = new TerminalManagerRuntime({ ...options, ptyAdapter }); | |
| 1073 | ||
| 1074 | return { | |
| 1075 | open: (input) => Effect.promise(() => runtime.open(input)), | |
| 1076 | write: (input) => Effect.promise(() => runtime.write(input)), | |
| 1077 | resize: (input) => Effect.promise(() => runtime.resize(input)), | |
| 1078 | clear: (input) => Effect.promise(() => runtime.clear(input)), | |
| 1079 | restart: (input) => Effect.promise(() => runtime.restart(input)), | |
| 1080 | close: (input) => Effect.promise(() => runtime.close(input)), | |
| 1081 | subscribe: (listener) => | |
| 1082 | Effect.sync(() => { | |
| 1083 | runtime.on("event", listener); | |
| 1084 | return () => { | |
| 1085 | runtime.off("event", listener); | |
| 1086 | }; | |
| 1087 | }), | |
| 1088 | dispose: () => | |
| 1089 | Effect.sync(() => { | |
| 1090 | runtime.dispose(); | |
| 1091 | }), | |
| 1092 | } satisfies TerminalManagerShape; | |
| 1093 | }), | |
| 1094 | ); | |
| 1095 | } |
apps/server/src/wsServer.test.ts+111-88| ... | ... | @@ -2,7 +2,6 @@ import * as Http from "node:http"; |
| 2 | 2 | import fs from "node:fs"; |
| 3 | 3 | import os from "node:os"; |
| 4 | 4 | import path from "node:path"; |
| 5 | import { EventEmitter } from "node:events"; | |
| 6 | 5 | |
| 7 | 6 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; |
| 8 | 7 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| ... | ... | @@ -38,7 +37,7 @@ import type { |
| 38 | 37 | TerminalSessionSnapshot, |
| 39 | 38 | TerminalWriteInput, |
| 40 | 39 | } from "@t3tools/contracts"; |
| 41 | import type { TerminalManager } from "./terminalManager"; | |
| 40 | import type { TerminalManagerShape } from "./terminalManager"; | |
| 42 | 41 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 43 | 42 | import { SqlClient } from "effect/unstable/sql"; |
| 44 | 43 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| ... | ... | @@ -63,111 +62,135 @@ const defaultOpenService: OpenShape = { |
| 63 | 62 | openInEditor: () => Effect.void, |
| 64 | 63 | }; |
| 65 | 64 | |
| 66 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { | |
| 65 | class MockTerminalManager implements TerminalManagerShape { | |
| 67 | 66 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 67 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); | |
| 68 | 68 | |
| 69 | 69 | private key(threadId: string, terminalId: string): string { |
| 70 | 70 | return `${threadId}\u0000${terminalId}`; |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 74 | const now = new Date().toISOString(); | |
| 75 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 76 | const snapshot: TerminalSessionSnapshot = { | |
| 77 | threadId: input.threadId, | |
| 78 | terminalId, | |
| 79 | cwd: input.cwd, | |
| 80 | status: "running", | |
| 81 | pid: 4242, | |
| 82 | history: "", | |
| 83 | exitCode: null, | |
| 84 | exitSignal: null, | |
| 85 | updatedAt: now, | |
| 86 | }; | |
| 87 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 88 | queueMicrotask(() => { | |
| 89 | this.emit("event", { | |
| 90 | type: "started", | |
| 73 | emitEvent(event: TerminalEvent): void { | |
| 74 | for (const listener of this.listeners) { | |
| 75 | listener(event); | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | subscriptionCount(): number { | |
| 80 | return this.listeners.size; | |
| 81 | } | |
| 82 | ||
| 83 | readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) => | |
| 84 | Effect.sync(() => { | |
| 85 | const now = new Date().toISOString(); | |
| 86 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 87 | const snapshot: TerminalSessionSnapshot = { | |
| 91 | 88 | threadId: input.threadId, |
| 92 | 89 | terminalId, |
| 93 | createdAt: now, | |
| 94 | snapshot, | |
| 90 | cwd: input.cwd, | |
| 91 | status: "running", | |
| 92 | pid: 4242, | |
| 93 | history: "", | |
| 94 | exitCode: null, | |
| 95 | exitSignal: null, | |
| 96 | updatedAt: now, | |
| 97 | }; | |
| 98 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 99 | queueMicrotask(() => { | |
| 100 | this.emitEvent({ | |
| 101 | type: "started", | |
| 102 | threadId: input.threadId, | |
| 103 | terminalId, | |
| 104 | createdAt: now, | |
| 105 | snapshot, | |
| 106 | }); | |
| 95 | 107 | }); |
| 108 | return snapshot; | |
| 96 | 109 | }); |
| 97 | return snapshot; | |
| 98 | } | |
| 99 | 110 | |
| 100 | async write(input: TerminalWriteInput): Promise<void> { | |
| 101 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 102 | const existing = this.sessions.get(this.key(input.threadId, terminalId)); | |
| 103 | if (!existing) { | |
| 104 | throw new Error(`Unknown terminal thread: ${input.threadId}`); | |
| 105 | } | |
| 106 | queueMicrotask(() => { | |
| 107 | this.emit("event", { | |
| 108 | type: "output", | |
| 109 | threadId: input.threadId, | |
| 110 | terminalId, | |
| 111 | createdAt: new Date().toISOString(), | |
| 112 | data: input.data, | |
| 111 | readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) => | |
| 112 | Effect.sync(() => { | |
| 113 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 114 | const existing = this.sessions.get(this.key(input.threadId, terminalId)); | |
| 115 | if (!existing) { | |
| 116 | throw new Error(`Unknown terminal thread: ${input.threadId}`); | |
| 117 | } | |
| 118 | queueMicrotask(() => { | |
| 119 | this.emitEvent({ | |
| 120 | type: "output", | |
| 121 | threadId: input.threadId, | |
| 122 | terminalId, | |
| 123 | createdAt: new Date().toISOString(), | |
| 124 | data: input.data, | |
| 125 | }); | |
| 113 | 126 | }); |
| 114 | 127 | }); |
| 115 | } | |
| 116 | 128 | |
| 117 | async resize(_input: TerminalResizeInput): Promise<void> {} | |
| 129 | readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void; | |
| 118 | 130 | |
| 119 | async clear(input: TerminalClearInput): Promise<void> { | |
| 120 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 121 | queueMicrotask(() => { | |
| 122 | this.emit("event", { | |
| 123 | type: "cleared", | |
| 124 | threadId: input.threadId, | |
| 125 | terminalId, | |
| 126 | createdAt: new Date().toISOString(), | |
| 131 | readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => | |
| 132 | Effect.sync(() => { | |
| 133 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 134 | queueMicrotask(() => { | |
| 135 | this.emitEvent({ | |
| 136 | type: "cleared", | |
| 137 | threadId: input.threadId, | |
| 138 | terminalId, | |
| 139 | createdAt: new Date().toISOString(), | |
| 140 | }); | |
| 127 | 141 | }); |
| 128 | 142 | }); |
| 129 | } | |
| 130 | 143 | |
| 131 | async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 132 | const now = new Date().toISOString(); | |
| 133 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 134 | const snapshot: TerminalSessionSnapshot = { | |
| 135 | threadId: input.threadId, | |
| 136 | terminalId, | |
| 137 | cwd: input.cwd, | |
| 138 | status: "running", | |
| 139 | pid: 5252, | |
| 140 | history: "", | |
| 141 | exitCode: null, | |
| 142 | exitSignal: null, | |
| 143 | updatedAt: now, | |
| 144 | }; | |
| 145 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 146 | queueMicrotask(() => { | |
| 147 | this.emit("event", { | |
| 148 | type: "restarted", | |
| 144 | readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => | |
| 145 | Effect.sync(() => { | |
| 146 | const now = new Date().toISOString(); | |
| 147 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 148 | const snapshot: TerminalSessionSnapshot = { | |
| 149 | 149 | threadId: input.threadId, |
| 150 | 150 | terminalId, |
| 151 | createdAt: now, | |
| 152 | snapshot, | |
| 151 | cwd: input.cwd, | |
| 152 | status: "running", | |
| 153 | pid: 5252, | |
| 154 | history: "", | |
| 155 | exitCode: null, | |
| 156 | exitSignal: null, | |
| 157 | updatedAt: now, | |
| 158 | }; | |
| 159 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 160 | queueMicrotask(() => { | |
| 161 | this.emitEvent({ | |
| 162 | type: "restarted", | |
| 163 | threadId: input.threadId, | |
| 164 | terminalId, | |
| 165 | createdAt: now, | |
| 166 | snapshot, | |
| 167 | }); | |
| 153 | 168 | }); |
| 169 | return snapshot; | |
| 154 | 170 | }); |
| 155 | return snapshot; | |
| 156 | } | |
| 157 | 171 | |
| 158 | async close(input: TerminalCloseInput): Promise<void> { | |
| 159 | if (input.terminalId) { | |
| 160 | this.sessions.delete(this.key(input.threadId, input.terminalId)); | |
| 161 | return; | |
| 162 | } | |
| 163 | for (const key of [...this.sessions.keys()]) { | |
| 164 | if (key.startsWith(`${input.threadId}\u0000`)) { | |
| 165 | this.sessions.delete(key); | |
| 172 | readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) => | |
| 173 | Effect.sync(() => { | |
| 174 | if (input.terminalId) { | |
| 175 | this.sessions.delete(this.key(input.threadId, input.terminalId)); | |
| 176 | return; | |
| 166 | 177 | } |
| 167 | } | |
| 168 | } | |
| 178 | for (const key of [...this.sessions.keys()]) { | |
| 179 | if (key.startsWith(`${input.threadId}\u0000`)) { | |
| 180 | this.sessions.delete(key); | |
| 181 | } | |
| 182 | } | |
| 183 | }); | |
| 184 | ||
| 185 | readonly subscribe: TerminalManagerShape["subscribe"] = (listener) => | |
| 186 | Effect.sync(() => { | |
| 187 | this.listeners.add(listener); | |
| 188 | return () => { | |
| 189 | this.listeners.delete(listener); | |
| 190 | }; | |
| 191 | }); | |
| 169 | 192 | |
| 170 | dispose(): void {} | |
| 193 | readonly dispose: TerminalManagerShape["dispose"] = () => Effect.void; | |
| 171 | 194 | } |
| 172 | 195 | |
| 173 | 196 | function connectWs(port: number, token?: string): Promise<WebSocket> { |
| ... | ... | @@ -320,7 +343,7 @@ describe("WebSocket Server", () => { |
| 320 | 343 | status: (input: { cwd: string }) => Promise<unknown>; |
| 321 | 344 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; |
| 322 | 345 | }; |
| 323 | terminalManager?: TerminalManager; | |
| 346 | terminalManager?: TerminalManagerShape; | |
| 324 | 347 | } = {}, |
| 325 | 348 | ): Promise<Http.Server> { |
| 326 | 349 | if (serverScope) { |
| ... | ... | @@ -990,7 +1013,7 @@ describe("WebSocket Server", () => { |
| 990 | 1013 | const terminalManager = new MockTerminalManager(); |
| 991 | 1014 | server = await createTestServer({ |
| 992 | 1015 | cwd: "/test", |
| 993 | terminalManager: terminalManager as unknown as TerminalManager, | |
| 1016 | terminalManager, | |
| 994 | 1017 | }); |
| 995 | 1018 | const addr = server.address(); |
| 996 | 1019 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | ... | @@ -1048,7 +1071,7 @@ describe("WebSocket Server", () => { |
| 1048 | 1071 | createdAt: new Date().toISOString(), |
| 1049 | 1072 | data: "manual test output\n", |
| 1050 | 1073 | }; |
| 1051 | terminalManager.emit("event", manualEvent); | |
| 1074 | terminalManager.emitEvent(manualEvent); | |
| 1052 | 1075 | |
| 1053 | 1076 | const push = (await waitForMessage(ws)) as WsPush; |
| 1054 | 1077 | expect(push.type).toBe("push"); |
| ... | ... | @@ -1060,15 +1083,15 @@ describe("WebSocket Server", () => { |
| 1060 | 1083 | const terminalManager = new MockTerminalManager(); |
| 1061 | 1084 | server = await createTestServer({ |
| 1062 | 1085 | cwd: "/test", |
| 1063 | terminalManager: terminalManager as unknown as TerminalManager, | |
| 1086 | terminalManager, | |
| 1064 | 1087 | }); |
| 1065 | 1088 | |
| 1066 | expect(terminalManager.listenerCount("event")).toBe(1); | |
| 1089 | expect(terminalManager.subscriptionCount()).toBe(1); | |
| 1067 | 1090 | |
| 1068 | 1091 | await closeTestServer(); |
| 1069 | 1092 | server = null; |
| 1070 | 1093 | |
| 1071 | expect(terminalManager.listenerCount("event")).toBe(0); | |
| 1094 | expect(terminalManager.subscriptionCount()).toBe(0); | |
| 1072 | 1095 | }); |
| 1073 | 1096 | |
| 1074 | 1097 | it("returns validation errors for invalid terminal open params", async () => { |
apps/server/src/wsServer.ts+16-13| ... | ... | @@ -30,7 +30,7 @@ import { |
| 30 | 30 | pullGitBranch, |
| 31 | 31 | removeGitWorktree, |
| 32 | 32 | } from "./git"; |
| 33 | import { TerminalManager } from "./terminalManager"; | |
| 33 | import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; | |
| 34 | 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 35 | 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 36 | 36 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| ... | ... | @@ -61,7 +61,7 @@ export interface ServerOptions { |
| 61 | 61 | autoBootstrapProjectFromCwd?: boolean | undefined; |
| 62 | 62 | logWebSocketEvents?: boolean | undefined; |
| 63 | 63 | gitManager?: GitManager | undefined; |
| 64 | terminalManager?: TerminalManager | undefined; | |
| 64 | terminalManager?: TerminalManagerShape | undefined; | |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | export interface ServerShape { |
| ... | ... | @@ -152,7 +152,7 @@ export type ServerCoreRuntimeServices = |
| 152 | 152 | | OrchestrationReactor |
| 153 | 153 | | Open; |
| 154 | 154 | |
| 155 | export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; | |
| 155 | export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService | TerminalManager; | |
| 156 | 156 | |
| 157 | 157 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( |
| 158 | 158 | "ServerLifecycleError", |
| ... | ... | @@ -172,10 +172,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 172 | 172 | Scope.Scope | ServerRuntimeServices | ServerConfig |
| 173 | 173 | > { |
| 174 | 174 | const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; |
| 175 | const defaultTerminalManager = yield* TerminalManager; | |
| 175 | 176 | const { |
| 176 | 177 | logWebSocketEvents: explicitLogWsEvents, |
| 177 | 178 | gitManager = new GitManager(), |
| 178 | terminalManager = new TerminalManager(), | |
| 179 | terminalManager = defaultTerminalManager, | |
| 179 | 180 | } = options; |
| 180 | 181 | const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; |
| 181 | 182 | |
| ... | ... | @@ -541,32 +542,32 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 541 | 542 | |
| 542 | 543 | case WS_METHODS.terminalOpen: { |
| 543 | 544 | const body = stripRequestTag(request.body); |
| 544 | return yield* Effect.promise(() => terminalManager.open(body)); | |
| 545 | return yield* terminalManager.open(body); | |
| 545 | 546 | } |
| 546 | 547 | |
| 547 | 548 | case WS_METHODS.terminalWrite: { |
| 548 | 549 | const body = stripRequestTag(request.body); |
| 549 | return yield* Effect.promise(() => terminalManager.write(body)); | |
| 550 | return yield* terminalManager.write(body); | |
| 550 | 551 | } |
| 551 | 552 | |
| 552 | 553 | case WS_METHODS.terminalResize: { |
| 553 | 554 | const body = stripRequestTag(request.body); |
| 554 | return yield* Effect.promise(() => terminalManager.resize(body)); | |
| 555 | return yield* terminalManager.resize(body); | |
| 555 | 556 | } |
| 556 | 557 | |
| 557 | 558 | case WS_METHODS.terminalClear: { |
| 558 | 559 | const body = stripRequestTag(request.body); |
| 559 | return yield* Effect.promise(() => terminalManager.clear(body)); | |
| 560 | return yield* terminalManager.clear(body); | |
| 560 | 561 | } |
| 561 | 562 | |
| 562 | 563 | case WS_METHODS.terminalRestart: { |
| 563 | 564 | const body = stripRequestTag(request.body); |
| 564 | return yield* Effect.promise(() => terminalManager.restart(body)); | |
| 565 | return yield* terminalManager.restart(body); | |
| 565 | 566 | } |
| 566 | 567 | |
| 567 | 568 | case WS_METHODS.terminalClose: { |
| 568 | 569 | const body = stripRequestTag(request.body); |
| 569 | return yield* Effect.promise(() => terminalManager.close(body)); | |
| 570 | return yield* terminalManager.close(body); | |
| 570 | 571 | } |
| 571 | 572 | |
| 572 | 573 | case WS_METHODS.serverGetConfig: |
| ... | ... | @@ -690,13 +691,15 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 690 | 691 | ), |
| 691 | 692 | ); |
| 692 | 693 | |
| 693 | terminalManager.on("event", onTerminalEvent); | |
| 694 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe(onTerminalEvent); | |
| 694 | 695 | yield* Effect.addFinalizer(() => |
| 695 | 696 | Effect.sync(() => { |
| 696 | terminalManager.off("event", onTerminalEvent); | |
| 697 | terminalManager.dispose(); | |
| 697 | unsubscribeTerminalEvents(); | |
| 698 | 698 | }), |
| 699 | 699 | ); |
| 700 | yield* Effect.addFinalizer(() => | |
| 701 | terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)), | |
| 702 | ); | |
| 700 | 703 | |
| 701 | 704 | yield* NodeHttpServer.make(() => httpServer, listenOptions); |
| 702 | 705 |
apps/server/tsconfig.json+1-1| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | "extends": "../../tsconfig.base.json", |
| 3 | 3 | "compilerOptions": { |
| 4 | 4 | "composite": true, |
| 5 | "types": ["node"], | |
| 5 | "types": ["node", "bun"], | |
| 6 | 6 | "lib": ["ES2023", "esnext.disposable"], |
| 7 | 7 | "noEmit": true, |
| 8 | 8 | "allowImportingTsExtensions": true, |
bun.lock+9| ... | ... | @@ -48,6 +48,7 @@ |
| 48 | 48 | "@effect/vitest": "4.0.0-beta.10", |
| 49 | 49 | "@t3tools/contracts": "workspace:*", |
| 50 | 50 | "@t3tools/web": "workspace:*", |
| 51 | "@types/bun": "^1.3.9", | |
| 51 | 52 | "@types/node": "^22.10.2", |
| 52 | 53 | "@types/ws": "^8.5.13", |
| 53 | 54 | "tsdown": "^0.20.3", |
| ... | ... | @@ -527,6 +528,8 @@ |
| 527 | 528 | |
| 528 | 529 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], |
| 529 | 530 | |
| 531 | "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], | |
| 532 | ||
| 530 | 533 | "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], |
| 531 | 534 | |
| 532 | 535 | "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], |
| ... | ... | @@ -629,6 +632,8 @@ |
| 629 | 632 | |
| 630 | 633 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], |
| 631 | 634 | |
| 635 | "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], | |
| 636 | ||
| 632 | 637 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], |
| 633 | 638 | |
| 634 | 639 | "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], |
| ... | ... | @@ -1451,6 +1456,8 @@ |
| 1451 | 1456 | |
| 1452 | 1457 | "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], |
| 1453 | 1458 | |
| 1459 | "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], | |
| 1460 | ||
| 1454 | 1461 | "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], |
| 1455 | 1462 | |
| 1456 | 1463 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], |
| ... | ... | @@ -1501,6 +1508,8 @@ |
| 1501 | 1508 | |
| 1502 | 1509 | "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], |
| 1503 | 1510 | |
| 1511 | "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | |
| 1512 | ||
| 1504 | 1513 | "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], |
| 1505 | 1514 | |
| 1506 | 1515 | "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], |