| ... | @@ -0,0 +1,154 @@ |
| 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. |