authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 19:01:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log470f282819079be867cdc1eaa52b0eeb632c14e7
tree59847f8a9a68bba2c6bd18bfa6c44ab9cf85c794
parent3fbd02c6cfc667dbf239cb325f00caa2e4ac662c

terminal


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
5Terminals appear to "hang" because PTY processes are exiting immediately at startup in the current runtime.
6
7Repro 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
12This 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
161. Server dev runtime uses Bun (`apps/server/package.json` `dev` script).
172. Terminal PTY implementation depends on `node-pty`.
183. Under Bun, spawned PTY shells exit immediately.
194. Frontend still mounts terminals, so UX looks like startup hang/blank terminal.
20
21## Design Goals
22
231. Do not store full terminal output in SQLite.
242. Preserve terminal topology and intent across reconnects/restarts:
25 - open/split/new/close
26 - active terminal/group
27 - cwd/env profile
283. Keep runtime truth accurate even when processes change outside the app.
294. 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
41Persist only control/state events, not output payloads.
42
43Recommended 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
65Use 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
75This keeps SQLite small while preserving replay capability.
76
77### 4) Reconciliation Loop (Runtime Truth)
78
79Do not treat client dispatch as sole truth.
80
81Maintain dual state:
82
83- Desired state: from event stream/projection.
84- Observed runtime state: from PTY host watchers (process exit, health checks, poll).
85
86On 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
91This makes out-of-band kills/restarts explicit and recoverable.
92
93### 5) Startup/Recovery Flow
94
951. Rebuild terminal projection from SQLite control events.
962. Load terminal layout/topology immediately for UI.
973. 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
1004. Resume incremental output from last persisted offset.
101
102## Data Model Additions
103
104Add 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
110Avoid embedding output blobs in projection snapshots.
111
112## Implementation Plan
113
114### Phase 0: Stop the bleeding
115
1161. Run PTY on Node runtime in all dev/prod paths (or ship Node sidecar now).
1172. Add startup diagnostics: runtime, PTY spawn result, immediate-exit telemetry.
1183. Surface clear UI error when PTY host unavailable.
119
120### Phase 1: Durable control events
121
1221. Introduce terminal aggregate + commands/events.
1232. Persist layout and terminal lifecycle intent events.
1243. Build projection for terminal UI state from events.
125
126### Phase 2: File-backed output + index
127
1281. Implement segmented append-only output logger.
1292. Persist only segment metadata/offsets in SQLite.
1303. Update terminal history restore to tail from segments (bounded bytes/lines).
131
132### Phase 3: Runtime reconciliation
133
1341. Add process observer + periodic verifier.
1352. Emit runtime observation events on drift.
1363. Add recovery tests for out-of-band process termination.
137
138### Phase 4: Hardening
139
1401. Backpressure and truncation policies for high-volume output.
1412. Crash-safe fsync/flush strategy for segment writes.
1423. Retention and compaction jobs.
143
144## Testing Strategy
145
146Backend tests should cover:
147
1481. Rebuild projection from control events (open/split/close/resize/active terminal).
1492. Runtime reconciliation when process is killed externally.
1503. Recovery after restart with existing segment files.
1514. Idempotent command handling on duplicate dispatches.
1525. Segment index consistency and retention behavior.
153
154Use 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 @@
3131 "@effect/vitest": "4.0.0-beta.10",
3232 "@t3tools/contracts": "workspace:*",
3333 "@t3tools/web": "workspace:*",
34 "@types/bun": "^1.3.9",
3435 "@types/node": "^22.10.2",
3536 "@types/ws": "^8.5.13",
3637 "tsdown": "^0.20.3",
apps/server/src/ptyAdapter.test.ts+43-32
......@@ -2,7 +2,9 @@ import fs from "node:fs";
22import os from "node:os";
33import path from "node:path";
44
5import { afterEach, describe, expect, it } from "vitest";
5import { assert, it, vi } from "@effect/vitest";
6import { Effect, Layer } from "effect";
7import { afterEach } from "vitest";
68
79import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter";
810
......@@ -10,45 +12,54 @@ function fileMode(filePath: string): number {
1012 return fs.statSync(filePath).mode & 0o777;
1113}
1214
13describe("ensureNodePtySpawnHelperExecutable", () => {
14 const tempDirs: string[] = [];
15const tempDirs: string[] = [];
1516
16 afterEach(() => {
17 for (const dir of tempDirs.splice(0, tempDirs.length)) {
18 fs.rmSync(dir, { recursive: true, force: true });
19 }
20 });
17afterEach(() => {
18 vi.unstubAllGlobals();
19 for (const dir of tempDirs.splice(0, tempDirs.length)) {
20 fs.rmSync(dir, { recursive: true, force: true });
21 }
22});
23
24const helperLayer = it.layer(Layer.empty);
2125
22 it("adds executable bits when helper exists but is not executable", () => {
23 if (process.platform === "win32") return;
26helperLayer("ensureNodePtySpawnHelperExecutable", (it) => {
27 it.effect("adds executable bits when helper exists but is not executable", () =>
28 Effect.sync(() => {
29 if (process.platform === "win32") return;
2430
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);
3036
31 ensureNodePtySpawnHelperExecutable(helperPath);
37 ensureNodePtySpawnHelperExecutable(helperPath);
3238
33 expect(fileMode(helperPath) & 0o111).toBe(0o111);
34 });
39 assert.equal(fileMode(helperPath) & 0o111, 0o111);
40 }),
41 );
3542
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;
3846
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);
4452
45 ensureNodePtySpawnHelperExecutable(helperPath);
53 ensureNodePtySpawnHelperExecutable(helperPath);
4654
47 expect(fileMode(helperPath) & 0o111).toBe(0o111);
48 });
55 assert.equal(fileMode(helperPath) & 0o111, 0o111);
56 }),
57 );
4958
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 );
5465});
apps/server/src/ptyAdapter.ts+139-14
......@@ -2,7 +2,7 @@ import fs from "node:fs";
22import { createRequire } from "node:module";
33import path from "node:path";
44
5import * as nodePty from "node-pty";
5import { Effect, Layer, ServiceMap } from "effect";
66
77export interface PtyExitEvent {
88 exitCode: number;
......@@ -27,10 +27,17 @@ export interface PtySpawnInput {
2727 env: NodeJS.ProcessEnv;
2828}
2929
30export interface PtyAdapter {
30export interface PtyAdapterShape {
3131 spawn(input: PtySpawnInput): PtyProcess;
3232}
3333
34export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()(
35 "terminal/PtyAdapter",
36) {
37 static readonly layer = () =>
38 typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive;
39}
40
3441const requireForNodePty = createRequire(import.meta.url);
3542let didEnsureSpawnHelperExecutable = false;
3643
......@@ -78,7 +85,7 @@ export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void
7885}
7986
8087class NodePtyProcess implements PtyProcess {
81 constructor(private readonly process: nodePty.IPty) {}
88 constructor(private readonly process: import("node-pty").IPty) {}
8289
8390 get pid(): number {
8491 return this.process.pid;
......@@ -116,16 +123,134 @@ class NodePtyProcess implements PtyProcess {
116123 }
117124}
118125
119export 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);
126class 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 }
130208 }
131209}
210export 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
230export 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";
2626import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
2727import { ProviderService } from "./provider/Services/ProviderService";
2828
29import { makeTerminalManagerLive, TerminalManager } from "./terminalManager";
30import { PtyAdapter } from "./ptyAdapter";
31
2932export function makeServerProviderLayer(): Layer.Layer<
3033 ProviderService,
3134 unknown,
......@@ -48,9 +51,13 @@ export function makeServerProviderLayer(): Layer.Layer<
4851}
4952
5053export function makeServerRuntimeServicesLayer(): Layer.Layer<
51 OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor,
54 | OrchestrationEngineService
55 | ProjectionSnapshotQuery
56 | CheckpointStore
57 | OrchestrationReactor
58 | TerminalManager,
5259 unknown,
53 SqlClient.SqlClient | ProviderService
60 SqlClient.SqlClient | ProviderService | ServerConfig
5461> {
5562 const orchestrationLayer = OrchestrationEngineLive.pipe(
5663 Layer.provide(OrchestrationProjectionPipelineLive),
......@@ -79,5 +86,12 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<
7986 Layer.provideMerge(checkpointReactorLayer),
8087 );
8188
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);
8397}
apps/server/src/terminalManager.test.ts+4-4
......@@ -9,8 +9,8 @@ import {
99} from "@t3tools/contracts";
1010import { afterEach, describe, expect, it } from "vitest";
1111
12import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
13import { TerminalManager } from "./terminalManager";
12import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
13import { TerminalManagerRuntime } from "./terminalManager";
1414
1515class FakePtyProcess implements PtyProcess {
1616 readonly writes: string[] = [];
......@@ -60,7 +60,7 @@ class FakePtyProcess implements PtyProcess {
6060 }
6161}
6262
63class FakePtyAdapter implements PtyAdapter {
63class FakePtyAdapter implements PtyAdapterShape {
6464 readonly spawnInputs: PtySpawnInput[] = [];
6565 readonly processes: FakePtyProcess[] = [];
6666 readonly spawnFailures: Error[] = [];
......@@ -150,7 +150,7 @@ describe("TerminalManager", () => {
150150 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
151151 tempDirs.push(logsDir);
152152 const ptyAdapter = new FakePtyAdapter();
153 const manager = new TerminalManager({
153 const manager = new TerminalManagerRuntime({
154154 logsDir,
155155 ptyAdapter,
156156 historyLineLimit,
apps/server/src/terminalManager.ts+57-10
......@@ -13,10 +13,10 @@ import {
1313 type TerminalSessionSnapshot,
1414 type TerminalSessionStatus,
1515} from "@t3tools/contracts";
16import { Schema } from "effect";
16import { Effect, Layer, Schema, ServiceMap } from "effect";
1717
1818import { createLogger } from "./logger";
19import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
2020import { runProcess } from "./processRunner";
2121
2222const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
......@@ -41,12 +41,27 @@ export interface TerminalManagerEvents {
4141export interface TerminalManagerOptions {
4242 logsDir?: string;
4343 historyLineLimit?: number;
44 ptyAdapter?: PtyAdapter;
44 ptyAdapter: PtyAdapterShape;
4545 shellResolver?: () => string;
4646 subprocessChecker?: TerminalSubprocessChecker;
4747 subprocessPollIntervalMs?: number;
4848}
4949
50export 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
61export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()(
62 "terminal/TerminalManager",
63) {}
64
5065interface TerminalSessionState {
5166 threadId: string;
5267 terminalId: string;
......@@ -73,8 +88,8 @@ interface ShellCandidate {
7388
7489interface TerminalStartInput extends TerminalOpenInput {
7590 cols: number;
76 rows: number
77};
91 rows: number;
92}
7893
7994function defaultShellResolver(): string {
8095 if (process.platform === "win32") {
......@@ -300,14 +315,14 @@ function normalizedRuntimeEnv(
300315 if (!env) return null;
301316 const entries = Object.entries(env);
302317 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)));
304319}
305320
306export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
321export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> {
307322 private readonly sessions = new Map<string, TerminalSessionState>();
308323 private readonly logsDir: string;
309324 private readonly historyLineLimit: number;
310 private readonly ptyAdapter: PtyAdapter;
325 private readonly ptyAdapter: PtyAdapterShape;
311326 private readonly shellResolver: () => string;
312327 private readonly persistQueues = new Map<string, Promise<void>>();
313328 private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
......@@ -320,11 +335,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
320335 private subprocessPollInFlight = false;
321336 private readonly logger = createLogger("terminal");
322337
323 constructor(options: TerminalManagerOptions = {}) {
338 constructor(options: TerminalManagerOptions) {
324339 super();
325340 this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals");
326341 this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
327 this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter();
342 this.ptyAdapter = options.ptyAdapter;
328343 this.shellResolver = options.shellResolver ?? defaultShellResolver;
329344 this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS;
330345 this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
......@@ -1046,3 +1061,35 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
10461061 }
10471062 }
10481063}
1064
1065export 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";
22import fs from "node:fs";
33import os from "node:os";
44import path from "node:path";
5import { EventEmitter } from "node:events";
65
76import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
87import { describe, expect, it, afterEach, vi } from "vitest";
......@@ -38,7 +37,7 @@ import type {
3837 TerminalSessionSnapshot,
3938 TerminalWriteInput,
4039} from "@t3tools/contracts";
41import type { TerminalManager } from "./terminalManager";
40import type { TerminalManagerShape } from "./terminalManager";
4241import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
4342import { SqlClient } from "effect/unstable/sql";
4443import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
......@@ -63,111 +62,135 @@ const defaultOpenService: OpenShape = {
6362 openInEditor: () => Effect.void,
6463};
6564
66class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
65class MockTerminalManager implements TerminalManagerShape {
6766 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
67 private readonly listeners = new Set<(event: TerminalEvent) => void>();
6868
6969 private key(threadId: string, terminalId: string): string {
7070 return `${threadId}\u0000${terminalId}`;
7171 }
7272
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 = {
9188 threadId: input.threadId,
9289 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 });
95107 });
108 return snapshot;
96109 });
97 return snapshot;
98 }
99110
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 });
113126 });
114127 });
115 }
116128
117 async resize(_input: TerminalResizeInput): Promise<void> {}
129 readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void;
118130
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 });
127141 });
128142 });
129 }
130143
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 = {
149149 threadId: input.threadId,
150150 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 });
153168 });
169 return snapshot;
154170 });
155 return snapshot;
156 }
157171
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;
166177 }
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 });
169192
170 dispose(): void {}
193 readonly dispose: TerminalManagerShape["dispose"] = () => Effect.void;
171194}
172195
173196function connectWs(port: number, token?: string): Promise<WebSocket> {
......@@ -320,7 +343,7 @@ describe("WebSocket Server", () => {
320343 status: (input: { cwd: string }) => Promise<unknown>;
321344 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
322345 };
323 terminalManager?: TerminalManager;
346 terminalManager?: TerminalManagerShape;
324347 } = {},
325348 ): Promise<Http.Server> {
326349 if (serverScope) {
......@@ -990,7 +1013,7 @@ describe("WebSocket Server", () => {
9901013 const terminalManager = new MockTerminalManager();
9911014 server = await createTestServer({
9921015 cwd: "/test",
993 terminalManager: terminalManager as unknown as TerminalManager,
1016 terminalManager,
9941017 });
9951018 const addr = server.address();
9961019 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
......@@ -1048,7 +1071,7 @@ describe("WebSocket Server", () => {
10481071 createdAt: new Date().toISOString(),
10491072 data: "manual test output\n",
10501073 };
1051 terminalManager.emit("event", manualEvent);
1074 terminalManager.emitEvent(manualEvent);
10521075
10531076 const push = (await waitForMessage(ws)) as WsPush;
10541077 expect(push.type).toBe("push");
......@@ -1060,15 +1083,15 @@ describe("WebSocket Server", () => {
10601083 const terminalManager = new MockTerminalManager();
10611084 server = await createTestServer({
10621085 cwd: "/test",
1063 terminalManager: terminalManager as unknown as TerminalManager,
1086 terminalManager,
10641087 });
10651088
1066 expect(terminalManager.listenerCount("event")).toBe(1);
1089 expect(terminalManager.subscriptionCount()).toBe(1);
10671090
10681091 await closeTestServer();
10691092 server = null;
10701093
1071 expect(terminalManager.listenerCount("event")).toBe(0);
1094 expect(terminalManager.subscriptionCount()).toBe(0);
10721095 });
10731096
10741097 it("returns validation errors for invalid terminal open params", async () => {
apps/server/src/wsServer.ts+16-13
......@@ -30,7 +30,7 @@ import {
3030 pullGitBranch,
3131 removeGitWorktree,
3232} from "./git";
33import { TerminalManager } from "./terminalManager";
33import { TerminalManager, type TerminalManagerShape } from "./terminalManager";
3434import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
3535import { searchWorkspaceEntries } from "./workspaceEntries";
3636import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
......@@ -61,7 +61,7 @@ export interface ServerOptions {
6161 autoBootstrapProjectFromCwd?: boolean | undefined;
6262 logWebSocketEvents?: boolean | undefined;
6363 gitManager?: GitManager | undefined;
64 terminalManager?: TerminalManager | undefined;
64 terminalManager?: TerminalManagerShape | undefined;
6565}
6666
6767export interface ServerShape {
......@@ -152,7 +152,7 @@ export type ServerCoreRuntimeServices =
152152 | OrchestrationReactor
153153 | Open;
154154
155export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService;
155export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService | TerminalManager;
156156
157157export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
158158 "ServerLifecycleError",
......@@ -172,10 +172,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
172172 Scope.Scope | ServerRuntimeServices | ServerConfig
173173> {
174174 const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig;
175 const defaultTerminalManager = yield* TerminalManager;
175176 const {
176177 logWebSocketEvents: explicitLogWsEvents,
177178 gitManager = new GitManager(),
178 terminalManager = new TerminalManager(),
179 terminalManager = defaultTerminalManager,
179180 } = options;
180181 const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web";
181182
......@@ -541,32 +542,32 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
541542
542543 case WS_METHODS.terminalOpen: {
543544 const body = stripRequestTag(request.body);
544 return yield* Effect.promise(() => terminalManager.open(body));
545 return yield* terminalManager.open(body);
545546 }
546547
547548 case WS_METHODS.terminalWrite: {
548549 const body = stripRequestTag(request.body);
549 return yield* Effect.promise(() => terminalManager.write(body));
550 return yield* terminalManager.write(body);
550551 }
551552
552553 case WS_METHODS.terminalResize: {
553554 const body = stripRequestTag(request.body);
554 return yield* Effect.promise(() => terminalManager.resize(body));
555 return yield* terminalManager.resize(body);
555556 }
556557
557558 case WS_METHODS.terminalClear: {
558559 const body = stripRequestTag(request.body);
559 return yield* Effect.promise(() => terminalManager.clear(body));
560 return yield* terminalManager.clear(body);
560561 }
561562
562563 case WS_METHODS.terminalRestart: {
563564 const body = stripRequestTag(request.body);
564 return yield* Effect.promise(() => terminalManager.restart(body));
565 return yield* terminalManager.restart(body);
565566 }
566567
567568 case WS_METHODS.terminalClose: {
568569 const body = stripRequestTag(request.body);
569 return yield* Effect.promise(() => terminalManager.close(body));
570 return yield* terminalManager.close(body);
570571 }
571572
572573 case WS_METHODS.serverGetConfig:
......@@ -690,13 +691,15 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
690691 ),
691692 );
692693
693 terminalManager.on("event", onTerminalEvent);
694 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(onTerminalEvent);
694695 yield* Effect.addFinalizer(() =>
695696 Effect.sync(() => {
696 terminalManager.off("event", onTerminalEvent);
697 terminalManager.dispose();
697 unsubscribeTerminalEvents();
698698 }),
699699 );
700 yield* Effect.addFinalizer(() =>
701 terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)),
702 );
700703
701704 yield* NodeHttpServer.make(() => httpServer, listenOptions);
702705
apps/server/tsconfig.json+1-1
......@@ -2,7 +2,7 @@
22 "extends": "../../tsconfig.base.json",
33 "compilerOptions": {
44 "composite": true,
5 "types": ["node"],
5 "types": ["node", "bun"],
66 "lib": ["ES2023", "esnext.disposable"],
77 "noEmit": true,
88 "allowImportingTsExtensions": true,
bun.lock+9
......@@ -48,6 +48,7 @@
4848 "@effect/vitest": "4.0.0-beta.10",
4949 "@t3tools/contracts": "workspace:*",
5050 "@t3tools/web": "workspace:*",
51 "@types/bun": "^1.3.9",
5152 "@types/node": "^22.10.2",
5253 "@types/ws": "^8.5.13",
5354 "tsdown": "^0.20.3",
......@@ -527,6 +528,8 @@
527528
528529 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
529530
531 "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
532
530533 "@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=="],
531534
532535 "@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 @@
629632
630633 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
631634
635 "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
636
632637 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
633638
634639 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
......@@ -1451,6 +1456,8 @@
14511456
14521457 "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=="],
14531458
1459 "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
1460
14541461 "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=="],
14551462
14561463 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
......@@ -1501,6 +1508,8 @@
15011508
15021509 "@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=="],
15031510
1511 "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
1512
15041513 "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
15051514
15061515 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],