| 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,6 +31,7 @@ |
| 31 | "@effect/vitest": "4.0.0-beta.10", | 31 | "@effect/vitest": "4.0.0-beta.10", |
| 32 | "@t3tools/contracts": "workspace:*", | 32 | "@t3tools/contracts": "workspace:*", |
| 33 | "@t3tools/web": "workspace:*", | 33 | "@t3tools/web": "workspace:*", |
| 34 | "@types/bun": "^1.3.9", | ||
| 34 | "@types/node": "^22.10.2", | 35 | "@types/node": "^22.10.2", |
| 35 | "@types/ws": "^8.5.13", | 36 | "@types/ws": "^8.5.13", |
| 36 | "tsdown": "^0.20.3", | 37 | "tsdown": "^0.20.3", |
apps/server/src/ptyAdapter.test.ts+43-32| ... | @@ -2,7 +2,9 @@ import fs from "node:fs"; | ... | @@ -2,7 +2,9 @@ import fs from "node:fs"; |
| 2 | import os from "node:os"; | 2 | import os from "node:os"; |
| 3 | import path from "node:path"; | 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 | import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; | 9 | import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; |
| 8 | 10 | ||
| ... | @@ -10,45 +12,54 @@ function fileMode(filePath: string): number { | ... | @@ -10,45 +12,54 @@ function fileMode(filePath: string): number { |
| 10 | return fs.statSync(filePath).mode & 0o777; | 12 | return fs.statSync(filePath).mode & 0o777; |
| 11 | } | 13 | } |
| 12 | 14 | ||
| 13 | describe("ensureNodePtySpawnHelperExecutable", () => { | 15 | const tempDirs: string[] = []; |
| 14 | const tempDirs: string[] = []; | ||
| 15 | 16 | ||
| 16 | afterEach(() => { | 17 | afterEach(() => { |
| 17 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | 18 | vi.unstubAllGlobals(); |
| 18 | fs.rmSync(dir, { recursive: true, force: true }); | 19 | for (const dir of tempDirs.splice(0, tempDirs.length)) { |
| 19 | } | 20 | fs.rmSync(dir, { recursive: true, force: true }); |
| 20 | }); | 21 | } |
| 22 | }); | ||
| 23 | |||
| 24 | const helperLayer = it.layer(Layer.empty); | ||
| 21 | 25 | ||
| 22 | it("adds executable bits when helper exists but is not executable", () => { | 26 | helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { |
| 23 | if (process.platform === "win32") return; | 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-")); | 31 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); |
| 26 | tempDirs.push(dir); | 32 | tempDirs.push(dir); |
| 27 | const helperPath = path.join(dir, "spawn-helper"); | 33 | const helperPath = path.join(dir, "spawn-helper"); |
| 28 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | 34 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); |
| 29 | fs.chmodSync(helperPath, 0o644); | 35 | fs.chmodSync(helperPath, 0o644); |
| 30 | 36 | ||
| 31 | ensureNodePtySpawnHelperExecutable(helperPath); | 37 | ensureNodePtySpawnHelperExecutable(helperPath); |
| 32 | 38 | ||
| 33 | expect(fileMode(helperPath) & 0o111).toBe(0o111); | 39 | assert.equal(fileMode(helperPath) & 0o111, 0o111); |
| 34 | }); | 40 | }), |
| 41 | ); | ||
| 35 | 42 | ||
| 36 | it("keeps executable helper as executable", () => { | 43 | it.effect("keeps executable helper as executable", () => |
| 37 | if (process.platform === "win32") return; | 44 | Effect.sync(() => { |
| 45 | if (process.platform === "win32") return; | ||
| 38 | 46 | ||
| 39 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | 47 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); |
| 40 | tempDirs.push(dir); | 48 | tempDirs.push(dir); |
| 41 | const helperPath = path.join(dir, "spawn-helper"); | 49 | const helperPath = path.join(dir, "spawn-helper"); |
| 42 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | 50 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); |
| 43 | fs.chmodSync(helperPath, 0o755); | 51 | fs.chmodSync(helperPath, 0o755); |
| 44 | 52 | ||
| 45 | ensureNodePtySpawnHelperExecutable(helperPath); | 53 | ensureNodePtySpawnHelperExecutable(helperPath); |
| 46 | 54 | ||
| 47 | expect(fileMode(helperPath) & 0o111).toBe(0o111); | 55 | assert.equal(fileMode(helperPath) & 0o111, 0o111); |
| 48 | }); | 56 | }), |
| 57 | ); | ||
| 49 | 58 | ||
| 50 | it("does not throw when helper path is missing", () => { | 59 | it.effect("does not throw when helper path is missing", () => |
| 51 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); | 60 | Effect.sync(() => { |
| 52 | expect(() => ensureNodePtySpawnHelperExecutable(missing)).not.toThrow(); | 61 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); |
| 53 | }); | 62 | ensureNodePtySpawnHelperExecutable(missing); |
| 63 | }), | ||
| 64 | ); | ||
| 54 | }); | 65 | }); |
apps/server/src/ptyAdapter.ts+139-14| ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; | ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; |
| 2 | import { createRequire } from "node:module"; | 2 | import { createRequire } from "node:module"; |
| 3 | import path from "node:path"; | 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 | export interface PtyExitEvent { | 7 | export interface PtyExitEvent { |
| 8 | exitCode: number; | 8 | exitCode: number; |
| ... | @@ -27,10 +27,17 @@ export interface PtySpawnInput { | ... | @@ -27,10 +27,17 @@ export interface PtySpawnInput { |
| 27 | env: NodeJS.ProcessEnv; | 27 | env: NodeJS.ProcessEnv; |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | export interface PtyAdapter { | 30 | export interface PtyAdapterShape { |
| 31 | spawn(input: PtySpawnInput): PtyProcess; | 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 | const requireForNodePty = createRequire(import.meta.url); | 41 | const requireForNodePty = createRequire(import.meta.url); |
| 35 | let didEnsureSpawnHelperExecutable = false; | 42 | let didEnsureSpawnHelperExecutable = false; |
| 36 | 43 | ||
| ... | @@ -78,7 +85,7 @@ export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void | ... | @@ -78,7 +85,7 @@ export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void |
| 78 | } | 85 | } |
| 79 | 86 | ||
| 80 | class NodePtyProcess implements PtyProcess { | 87 | class NodePtyProcess implements PtyProcess { |
| 81 | constructor(private readonly process: nodePty.IPty) {} | 88 | constructor(private readonly process: import("node-pty").IPty) {} |
| 82 | 89 | ||
| 83 | get pid(): number { | 90 | get pid(): number { |
| 84 | return this.process.pid; | 91 | return this.process.pid; |
| ... | @@ -116,16 +123,134 @@ class NodePtyProcess implements PtyProcess { | ... | @@ -116,16 +123,134 @@ class NodePtyProcess implements PtyProcess { |
| 116 | } | 123 | } |
| 117 | } | 124 | } |
| 118 | 125 | ||
| 119 | export class NodePtyAdapter implements PtyAdapter { | 126 | class BunPtyProcess implements PtyProcess { |
| 120 | spawn(input: PtySpawnInput): PtyProcess { | 127 | private readonly dataListeners = new Set<(data: string) => void>(); |
| 121 | ensureNodePtySpawnHelperExecutable(); | 128 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); |
| 122 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | 129 | private readonly decoder = new TextDecoder(); |
| 123 | cwd: input.cwd, | 130 | private didExit = false; |
| 124 | cols: input.cols, | 131 | |
| 125 | rows: input.rows, | 132 | constructor(private readonly process: Bun.Subprocess) { |
| 126 | env: input.env, | 133 | void this.process.exited |
| 127 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | 134 | .then((exitCode) => { |
| 128 | }); | 135 | this.emitExit({ |
| 129 | return new NodePtyProcess(ptyProcess); | 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,6 +26,9 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 26 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | 26 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 27 | import { ProviderService } from "./provider/Services/ProviderService"; | 27 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 28 | 28 | ||
| 29 | import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; | ||
| 30 | import { PtyAdapter } from "./ptyAdapter"; | ||
| 31 | |||
| 29 | export function makeServerProviderLayer(): Layer.Layer< | 32 | export function makeServerProviderLayer(): Layer.Layer< |
| 30 | ProviderService, | 33 | ProviderService, |
| 31 | unknown, | 34 | unknown, |
| ... | @@ -48,9 +51,13 @@ export function makeServerProviderLayer(): Layer.Layer< | ... | @@ -48,9 +51,13 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 48 | } | 51 | } |
| 49 | 52 | ||
| 50 | export function makeServerRuntimeServicesLayer(): Layer.Layer< | 53 | export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 51 | OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor, | 54 | | OrchestrationEngineService |
| 55 | | ProjectionSnapshotQuery | ||
| 56 | | CheckpointStore | ||
| 57 | | OrchestrationReactor | ||
| 58 | | TerminalManager, | ||
| 52 | unknown, | 59 | unknown, |
| 53 | SqlClient.SqlClient | ProviderService | 60 | SqlClient.SqlClient | ProviderService | ServerConfig |
| 54 | > { | 61 | > { |
| 55 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 62 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 56 | Layer.provide(OrchestrationProjectionPipelineLive), | 63 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | @@ -79,5 +86,12 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< | ... | @@ -79,5 +86,12 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 79 | Layer.provideMerge(checkpointReactorLayer), | 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,8 +9,8 @@ import { |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { afterEach, describe, expect, it } from "vitest"; | 10 | import { afterEach, describe, expect, it } from "vitest"; |
| 11 | 11 | ||
| 12 | import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; | 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; |
| 13 | import { TerminalManager } from "./terminalManager"; | 13 | import { TerminalManagerRuntime } from "./terminalManager"; |
| 14 | 14 | ||
| 15 | class FakePtyProcess implements PtyProcess { | 15 | class FakePtyProcess implements PtyProcess { |
| 16 | readonly writes: string[] = []; | 16 | readonly writes: string[] = []; |
| ... | @@ -60,7 +60,7 @@ class FakePtyProcess implements PtyProcess { | ... | @@ -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 | readonly spawnInputs: PtySpawnInput[] = []; | 64 | readonly spawnInputs: PtySpawnInput[] = []; |
| 65 | readonly processes: FakePtyProcess[] = []; | 65 | readonly processes: FakePtyProcess[] = []; |
| 66 | readonly spawnFailures: Error[] = []; | 66 | readonly spawnFailures: Error[] = []; |
| ... | @@ -150,7 +150,7 @@ describe("TerminalManager", () => { | ... | @@ -150,7 +150,7 @@ describe("TerminalManager", () => { |
| 150 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); | 150 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); |
| 151 | tempDirs.push(logsDir); | 151 | tempDirs.push(logsDir); |
| 152 | const ptyAdapter = new FakePtyAdapter(); | 152 | const ptyAdapter = new FakePtyAdapter(); |
| 153 | const manager = new TerminalManager({ | 153 | const manager = new TerminalManagerRuntime({ |
| 154 | logsDir, | 154 | logsDir, |
| 155 | ptyAdapter, | 155 | ptyAdapter, |
| 156 | historyLineLimit, | 156 | historyLineLimit, |
apps/server/src/terminalManager.ts+57-10| ... | @@ -13,10 +13,10 @@ import { | ... | @@ -13,10 +13,10 @@ import { |
| 13 | type TerminalSessionSnapshot, | 13 | type TerminalSessionSnapshot, |
| 14 | type TerminalSessionStatus, | 14 | type TerminalSessionStatus, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import { Schema } from "effect"; | 16 | import { Effect, Layer, Schema, ServiceMap } from "effect"; |
| 17 | 17 | ||
| 18 | import { createLogger } from "./logger"; | 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 | import { runProcess } from "./processRunner"; | 20 | import { runProcess } from "./processRunner"; |
| 21 | 21 | ||
| 22 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | 22 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| ... | @@ -41,12 +41,27 @@ export interface TerminalManagerEvents { | ... | @@ -41,12 +41,27 @@ export interface TerminalManagerEvents { |
| 41 | export interface TerminalManagerOptions { | 41 | export interface TerminalManagerOptions { |
| 42 | logsDir?: string; | 42 | logsDir?: string; |
| 43 | historyLineLimit?: number; | 43 | historyLineLimit?: number; |
| 44 | ptyAdapter?: PtyAdapter; | 44 | ptyAdapter: PtyAdapterShape; |
| 45 | shellResolver?: () => string; | 45 | shellResolver?: () => string; |
| 46 | subprocessChecker?: TerminalSubprocessChecker; | 46 | subprocessChecker?: TerminalSubprocessChecker; |
| 47 | subprocessPollIntervalMs?: number; | 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 | interface TerminalSessionState { | 65 | interface TerminalSessionState { |
| 51 | threadId: string; | 66 | threadId: string; |
| 52 | terminalId: string; | 67 | terminalId: string; |
| ... | @@ -73,8 +88,8 @@ interface ShellCandidate { | ... | @@ -73,8 +88,8 @@ interface ShellCandidate { |
| 73 | 88 | ||
| 74 | interface TerminalStartInput extends TerminalOpenInput { | 89 | interface TerminalStartInput extends TerminalOpenInput { |
| 75 | cols: number; | 90 | cols: number; |
| 76 | rows: number | 91 | rows: number; |
| 77 | }; | 92 | } |
| 78 | 93 | ||
| 79 | function defaultShellResolver(): string { | 94 | function defaultShellResolver(): string { |
| 80 | if (process.platform === "win32") { | 95 | if (process.platform === "win32") { |
| ... | @@ -300,14 +315,14 @@ function normalizedRuntimeEnv( | ... | @@ -300,14 +315,14 @@ function normalizedRuntimeEnv( |
| 300 | if (!env) return null; | 315 | if (!env) return null; |
| 301 | const entries = Object.entries(env); | 316 | const entries = Object.entries(env); |
| 302 | if (entries.length === 0) return null; | 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 | private readonly sessions = new Map<string, TerminalSessionState>(); | 322 | private readonly sessions = new Map<string, TerminalSessionState>(); |
| 308 | private readonly logsDir: string; | 323 | private readonly logsDir: string; |
| 309 | private readonly historyLineLimit: number; | 324 | private readonly historyLineLimit: number; |
| 310 | private readonly ptyAdapter: PtyAdapter; | 325 | private readonly ptyAdapter: PtyAdapterShape; |
| 311 | private readonly shellResolver: () => string; | 326 | private readonly shellResolver: () => string; |
| 312 | private readonly persistQueues = new Map<string, Promise<void>>(); | 327 | private readonly persistQueues = new Map<string, Promise<void>>(); |
| 313 | private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); | 328 | private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); |
| ... | @@ -320,11 +335,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -320,11 +335,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 320 | private subprocessPollInFlight = false; | 335 | private subprocessPollInFlight = false; |
| 321 | private readonly logger = createLogger("terminal"); | 336 | private readonly logger = createLogger("terminal"); |
| 322 | 337 | ||
| 323 | constructor(options: TerminalManagerOptions = {}) { | 338 | constructor(options: TerminalManagerOptions) { |
| 324 | super(); | 339 | super(); |
| 325 | this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); | 340 | this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); |
| 326 | this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | 341 | this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; |
| 327 | this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); | 342 | this.ptyAdapter = options.ptyAdapter; |
| 328 | this.shellResolver = options.shellResolver ?? defaultShellResolver; | 343 | this.shellResolver = options.shellResolver ?? defaultShellResolver; |
| 329 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; | 344 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; |
| 330 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | 345 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; |
| ... | @@ -1046,3 +1061,35 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -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,7 +2,6 @@ import * as Http from "node:http"; |
| 2 | import fs from "node:fs"; | 2 | import fs from "node:fs"; |
| 3 | import os from "node:os"; | 3 | import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | import { EventEmitter } from "node:events"; | ||
| 6 | 5 | ||
| 7 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; | 6 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; |
| 8 | import { describe, expect, it, afterEach, vi } from "vitest"; | 7 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| ... | @@ -38,7 +37,7 @@ import type { | ... | @@ -38,7 +37,7 @@ import type { |
| 38 | TerminalSessionSnapshot, | 37 | TerminalSessionSnapshot, |
| 39 | TerminalWriteInput, | 38 | TerminalWriteInput, |
| 40 | } from "@t3tools/contracts"; | 39 | } from "@t3tools/contracts"; |
| 41 | import type { TerminalManager } from "./terminalManager"; | 40 | import type { TerminalManagerShape } from "./terminalManager"; |
| 42 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | 41 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 43 | import { SqlClient } from "effect/unstable/sql"; | 42 | import { SqlClient } from "effect/unstable/sql"; |
| 44 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 43 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| ... | @@ -63,111 +62,135 @@ const defaultOpenService: OpenShape = { | ... | @@ -63,111 +62,135 @@ const defaultOpenService: OpenShape = { |
| 63 | openInEditor: () => Effect.void, | 62 | openInEditor: () => Effect.void, |
| 64 | }; | 63 | }; |
| 65 | 64 | ||
| 66 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { | 65 | class MockTerminalManager implements TerminalManagerShape { |
| 67 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); | 66 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 67 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); | ||
| 68 | 68 | ||
| 69 | private key(threadId: string, terminalId: string): string { | 69 | private key(threadId: string, terminalId: string): string { |
| 70 | return `${threadId}\u0000${terminalId}`; | 70 | return `${threadId}\u0000${terminalId}`; |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | 73 | emitEvent(event: TerminalEvent): void { |
| 74 | const now = new Date().toISOString(); | 74 | for (const listener of this.listeners) { |
| 75 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 75 | listener(event); |
| 76 | const snapshot: TerminalSessionSnapshot = { | 76 | } |
| 77 | threadId: input.threadId, | 77 | } |
| 78 | terminalId, | 78 | |
| 79 | cwd: input.cwd, | 79 | subscriptionCount(): number { |
| 80 | status: "running", | 80 | return this.listeners.size; |
| 81 | pid: 4242, | 81 | } |
| 82 | history: "", | 82 | |
| 83 | exitCode: null, | 83 | readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) => |
| 84 | exitSignal: null, | 84 | Effect.sync(() => { |
| 85 | updatedAt: now, | 85 | const now = new Date().toISOString(); |
| 86 | }; | 86 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; |
| 87 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | 87 | const snapshot: TerminalSessionSnapshot = { |
| 88 | queueMicrotask(() => { | ||
| 89 | this.emit("event", { | ||
| 90 | type: "started", | ||
| 91 | threadId: input.threadId, | 88 | threadId: input.threadId, |
| 92 | terminalId, | 89 | terminalId, |
| 93 | createdAt: now, | 90 | cwd: input.cwd, |
| 94 | snapshot, | 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> { | 111 | readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) => |
| 101 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 112 | Effect.sync(() => { |
| 102 | const existing = this.sessions.get(this.key(input.threadId, terminalId)); | 113 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; |
| 103 | if (!existing) { | 114 | const existing = this.sessions.get(this.key(input.threadId, terminalId)); |
| 104 | throw new Error(`Unknown terminal thread: ${input.threadId}`); | 115 | if (!existing) { |
| 105 | } | 116 | throw new Error(`Unknown terminal thread: ${input.threadId}`); |
| 106 | queueMicrotask(() => { | 117 | } |
| 107 | this.emit("event", { | 118 | queueMicrotask(() => { |
| 108 | type: "output", | 119 | this.emitEvent({ |
| 109 | threadId: input.threadId, | 120 | type: "output", |
| 110 | terminalId, | 121 | threadId: input.threadId, |
| 111 | createdAt: new Date().toISOString(), | 122 | terminalId, |
| 112 | data: input.data, | 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> { | 131 | readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => |
| 120 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 132 | Effect.sync(() => { |
| 121 | queueMicrotask(() => { | 133 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; |
| 122 | this.emit("event", { | 134 | queueMicrotask(() => { |
| 123 | type: "cleared", | 135 | this.emitEvent({ |
| 124 | threadId: input.threadId, | 136 | type: "cleared", |
| 125 | terminalId, | 137 | threadId: input.threadId, |
| 126 | createdAt: new Date().toISOString(), | 138 | terminalId, |
| 139 | createdAt: new Date().toISOString(), | ||
| 140 | }); | ||
| 127 | }); | 141 | }); |
| 128 | }); | 142 | }); |
| 129 | } | ||
| 130 | 143 | ||
| 131 | async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | 144 | readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => |
| 132 | const now = new Date().toISOString(); | 145 | Effect.sync(() => { |
| 133 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 146 | const now = new Date().toISOString(); |
| 134 | const snapshot: TerminalSessionSnapshot = { | 147 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; |
| 135 | threadId: input.threadId, | 148 | const snapshot: TerminalSessionSnapshot = { |
| 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", | ||
| 149 | threadId: input.threadId, | 149 | threadId: input.threadId, |
| 150 | terminalId, | 150 | terminalId, |
| 151 | createdAt: now, | 151 | cwd: input.cwd, |
| 152 | snapshot, | 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> { | 172 | readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) => |
| 159 | if (input.terminalId) { | 173 | Effect.sync(() => { |
| 160 | this.sessions.delete(this.key(input.threadId, input.terminalId)); | 174 | if (input.terminalId) { |
| 161 | return; | 175 | this.sessions.delete(this.key(input.threadId, input.terminalId)); |
| 162 | } | 176 | return; |
| 163 | for (const key of [...this.sessions.keys()]) { | ||
| 164 | if (key.startsWith(`${input.threadId}\u0000`)) { | ||
| 165 | this.sessions.delete(key); | ||
| 166 | } | 177 | } |
| 167 | } | 178 | for (const key of [...this.sessions.keys()]) { |
| 168 | } | 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 | function connectWs(port: number, token?: string): Promise<WebSocket> { | 196 | function connectWs(port: number, token?: string): Promise<WebSocket> { |
| ... | @@ -320,7 +343,7 @@ describe("WebSocket Server", () => { | ... | @@ -320,7 +343,7 @@ describe("WebSocket Server", () => { |
| 320 | status: (input: { cwd: string }) => Promise<unknown>; | 343 | status: (input: { cwd: string }) => Promise<unknown>; |
| 321 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; | 344 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; |
| 322 | }; | 345 | }; |
| 323 | terminalManager?: TerminalManager; | 346 | terminalManager?: TerminalManagerShape; |
| 324 | } = {}, | 347 | } = {}, |
| 325 | ): Promise<Http.Server> { | 348 | ): Promise<Http.Server> { |
| 326 | if (serverScope) { | 349 | if (serverScope) { |
| ... | @@ -990,7 +1013,7 @@ describe("WebSocket Server", () => { | ... | @@ -990,7 +1013,7 @@ describe("WebSocket Server", () => { |
| 990 | const terminalManager = new MockTerminalManager(); | 1013 | const terminalManager = new MockTerminalManager(); |
| 991 | server = await createTestServer({ | 1014 | server = await createTestServer({ |
| 992 | cwd: "/test", | 1015 | cwd: "/test", |
| 993 | terminalManager: terminalManager as unknown as TerminalManager, | 1016 | terminalManager, |
| 994 | }); | 1017 | }); |
| 995 | const addr = server.address(); | 1018 | const addr = server.address(); |
| 996 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1019 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | @@ -1048,7 +1071,7 @@ describe("WebSocket Server", () => { | ... | @@ -1048,7 +1071,7 @@ describe("WebSocket Server", () => { |
| 1048 | createdAt: new Date().toISOString(), | 1071 | createdAt: new Date().toISOString(), |
| 1049 | data: "manual test output\n", | 1072 | data: "manual test output\n", |
| 1050 | }; | 1073 | }; |
| 1051 | terminalManager.emit("event", manualEvent); | 1074 | terminalManager.emitEvent(manualEvent); |
| 1052 | 1075 | ||
| 1053 | const push = (await waitForMessage(ws)) as WsPush; | 1076 | const push = (await waitForMessage(ws)) as WsPush; |
| 1054 | expect(push.type).toBe("push"); | 1077 | expect(push.type).toBe("push"); |
| ... | @@ -1060,15 +1083,15 @@ describe("WebSocket Server", () => { | ... | @@ -1060,15 +1083,15 @@ describe("WebSocket Server", () => { |
| 1060 | const terminalManager = new MockTerminalManager(); | 1083 | const terminalManager = new MockTerminalManager(); |
| 1061 | server = await createTestServer({ | 1084 | server = await createTestServer({ |
| 1062 | cwd: "/test", | 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 | await closeTestServer(); | 1091 | await closeTestServer(); |
| 1069 | server = null; | 1092 | server = null; |
| 1070 | 1093 | ||
| 1071 | expect(terminalManager.listenerCount("event")).toBe(0); | 1094 | expect(terminalManager.subscriptionCount()).toBe(0); |
| 1072 | }); | 1095 | }); |
| 1073 | 1096 | ||
| 1074 | it("returns validation errors for invalid terminal open params", async () => { | 1097 | it("returns validation errors for invalid terminal open params", async () => { |
apps/server/src/wsServer.ts+16-13| ... | @@ -30,7 +30,7 @@ import { | ... | @@ -30,7 +30,7 @@ import { |
| 30 | pullGitBranch, | 30 | pullGitBranch, |
| 31 | removeGitWorktree, | 31 | removeGitWorktree, |
| 32 | } from "./git"; | 32 | } from "./git"; |
| 33 | import { TerminalManager } from "./terminalManager"; | 33 | import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 36 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | 36 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| ... | @@ -61,7 +61,7 @@ export interface ServerOptions { | ... | @@ -61,7 +61,7 @@ export interface ServerOptions { |
| 61 | autoBootstrapProjectFromCwd?: boolean | undefined; | 61 | autoBootstrapProjectFromCwd?: boolean | undefined; |
| 62 | logWebSocketEvents?: boolean | undefined; | 62 | logWebSocketEvents?: boolean | undefined; |
| 63 | gitManager?: GitManager | undefined; | 63 | gitManager?: GitManager | undefined; |
| 64 | terminalManager?: TerminalManager | undefined; | 64 | terminalManager?: TerminalManagerShape | undefined; |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | export interface ServerShape { | 67 | export interface ServerShape { |
| ... | @@ -152,7 +152,7 @@ export type ServerCoreRuntimeServices = | ... | @@ -152,7 +152,7 @@ export type ServerCoreRuntimeServices = |
| 152 | | OrchestrationReactor | 152 | | OrchestrationReactor |
| 153 | | Open; | 153 | | Open; |
| 154 | 154 | ||
| 155 | export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; | 155 | export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService | TerminalManager; |
| 156 | 156 | ||
| 157 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( | 157 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( |
| 158 | "ServerLifecycleError", | 158 | "ServerLifecycleError", |
| ... | @@ -172,10 +172,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -172,10 +172,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 172 | Scope.Scope | ServerRuntimeServices | ServerConfig | 172 | Scope.Scope | ServerRuntimeServices | ServerConfig |
| 173 | > { | 173 | > { |
| 174 | const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; | 174 | const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; |
| 175 | const defaultTerminalManager = yield* TerminalManager; | ||
| 175 | const { | 176 | const { |
| 176 | logWebSocketEvents: explicitLogWsEvents, | 177 | logWebSocketEvents: explicitLogWsEvents, |
| 177 | gitManager = new GitManager(), | 178 | gitManager = new GitManager(), |
| 178 | terminalManager = new TerminalManager(), | 179 | terminalManager = defaultTerminalManager, |
| 179 | } = options; | 180 | } = options; |
| 180 | const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; | 181 | const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; |
| 181 | 182 | ||
| ... | @@ -541,32 +542,32 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -541,32 +542,32 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 541 | 542 | ||
| 542 | case WS_METHODS.terminalOpen: { | 543 | case WS_METHODS.terminalOpen: { |
| 543 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.terminalWrite: { | 548 | case WS_METHODS.terminalWrite: { |
| 548 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.terminalResize: { | 553 | case WS_METHODS.terminalResize: { |
| 553 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.terminalClear: { | 558 | case WS_METHODS.terminalClear: { |
| 558 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.terminalRestart: { | 563 | case WS_METHODS.terminalRestart: { |
| 563 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.terminalClose: { | 568 | case WS_METHODS.terminalClose: { |
| 568 | const body = stripRequestTag(request.body); | 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 | case WS_METHODS.serverGetConfig: | 573 | case WS_METHODS.serverGetConfig: |
| ... | @@ -690,13 +691,15 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -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 | yield* Effect.addFinalizer(() => | 695 | yield* Effect.addFinalizer(() => |
| 695 | Effect.sync(() => { | 696 | Effect.sync(() => { |
| 696 | terminalManager.off("event", onTerminalEvent); | 697 | unsubscribeTerminalEvents(); |
| 697 | terminalManager.dispose(); | ||
| 698 | }), | 698 | }), |
| 699 | ); | 699 | ); |
| 700 | yield* Effect.addFinalizer(() => | ||
| 701 | terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)), | ||
| 702 | ); | ||
| 700 | 703 | ||
| 701 | yield* NodeHttpServer.make(() => httpServer, listenOptions); | 704 | yield* NodeHttpServer.make(() => httpServer, listenOptions); |
| 702 | 705 |
apps/server/tsconfig.json+1-1| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | "extends": "../../tsconfig.base.json", | 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": { | 3 | "compilerOptions": { |
| 4 | "composite": true, | 4 | "composite": true, |
| 5 | "types": ["node"], | 5 | "types": ["node", "bun"], |
| 6 | "lib": ["ES2023", "esnext.disposable"], | 6 | "lib": ["ES2023", "esnext.disposable"], |
| 7 | "noEmit": true, | 7 | "noEmit": true, |
| 8 | "allowImportingTsExtensions": true, | 8 | "allowImportingTsExtensions": true, |
bun.lock+9| ... | @@ -48,6 +48,7 @@ | ... | @@ -48,6 +48,7 @@ |
| 48 | "@effect/vitest": "4.0.0-beta.10", | 48 | "@effect/vitest": "4.0.0-beta.10", |
| 49 | "@t3tools/contracts": "workspace:*", | 49 | "@t3tools/contracts": "workspace:*", |
| 50 | "@t3tools/web": "workspace:*", | 50 | "@t3tools/web": "workspace:*", |
| 51 | "@types/bun": "^1.3.9", | ||
| 51 | "@types/node": "^22.10.2", | 52 | "@types/node": "^22.10.2", |
| 52 | "@types/ws": "^8.5.13", | 53 | "@types/ws": "^8.5.13", |
| 53 | "tsdown": "^0.20.3", | 54 | "tsdown": "^0.20.3", |
| ... | @@ -527,6 +528,8 @@ | ... | @@ -527,6 +528,8 @@ |
| 527 | 528 | ||
| 528 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], | 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 | "@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=="], | 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 | "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], | 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,6 +632,8 @@ |
| 629 | 632 | ||
| 630 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], | 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 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], | 637 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], |
| 633 | 638 | ||
| 634 | "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], | 639 | "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], |
| ... | @@ -1451,6 +1456,8 @@ | ... | @@ -1451,6 +1456,8 @@ |
| 1451 | 1456 | ||
| 1452 | "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=="], | 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 | "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=="], | 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 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], | 1463 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], |
| ... | @@ -1501,6 +1508,8 @@ | ... | @@ -1501,6 +1508,8 @@ |
| 1501 | 1508 | ||
| 1502 | "@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=="], | 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 | "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | 1513 | "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], |
| 1505 | 1514 | ||
| 1506 | "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], | 1515 | "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], |