| 1 | import type { |
| 2 | EnvironmentId, |
| 3 | TerminalAttachStreamEvent, |
| 4 | TerminalMetadataStreamEvent, |
| 5 | TerminalSessionSnapshot, |
| 6 | TerminalSummary, |
| 7 | ThreadId, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { |
| 10 | appendOutput, |
| 11 | DEFAULT_MAX_TERMINAL_BUFFER_BYTES, |
| 12 | EMPTY_TERMINAL_OUTPUT_STATE, |
| 13 | resetOutput, |
| 14 | type TerminalOutputState, |
| 15 | } from "./terminalOutput.ts"; |
| 16 | |
| 17 | export { |
| 18 | DEFAULT_MAX_TERMINAL_BUFFER_BYTES, |
| 19 | INITIAL_TERMINAL_OUTPUT_CURSOR, |
| 20 | readTerminalOutputUpdate, |
| 21 | terminalOutputText, |
| 22 | type TerminalOutputCursor, |
| 23 | type TerminalOutputState, |
| 24 | type TerminalOutputUpdate, |
| 25 | } from "./terminalOutput.ts"; |
| 26 | |
| 27 | export interface TerminalSessionState { |
| 28 | readonly summary: TerminalSummary | null; |
| 29 | readonly output: TerminalOutputState; |
| 30 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 31 | readonly error: string | null; |
| 32 | readonly hasRunningSubprocess: boolean; |
| 33 | readonly updatedAt: string | null; |
| 34 | readonly version: number; |
| 35 | readonly lifecycleVersion: number; |
| 36 | } |
| 37 | |
| 38 | export interface TerminalBufferState { |
| 39 | readonly output: TerminalOutputState; |
| 40 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 41 | readonly error: string | null; |
| 42 | readonly updatedAt: string | null; |
| 43 | readonly version: number; |
| 44 | readonly lifecycleVersion: number; |
| 45 | } |
| 46 | |
| 47 | export interface KnownTerminalSessionTarget { |
| 48 | readonly environmentId: EnvironmentId; |
| 49 | readonly threadId: ThreadId; |
| 50 | readonly terminalId: string; |
| 51 | } |
| 52 | |
| 53 | export interface KnownTerminalSession { |
| 54 | readonly target: KnownTerminalSessionTarget; |
| 55 | readonly state: TerminalSessionState; |
| 56 | } |
| 57 | |
| 58 | export function selectRunningSubprocessTerminalIds( |
| 59 | sessions: ReadonlyArray<KnownTerminalSession>, |
| 60 | ): ReadonlyArray<string> { |
| 61 | return sessions |
| 62 | .filter((session) => session.state.hasRunningSubprocess) |
| 63 | .map((session) => session.target.terminalId); |
| 64 | } |
| 65 | |
| 66 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ |
| 67 | output: EMPTY_TERMINAL_OUTPUT_STATE, |
| 68 | status: "closed", |
| 69 | error: null, |
| 70 | updatedAt: null, |
| 71 | version: 0, |
| 72 | lifecycleVersion: 0, |
| 73 | }); |
| 74 | |
| 75 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ |
| 76 | summary: null, |
| 77 | output: EMPTY_TERMINAL_OUTPUT_STATE, |
| 78 | status: "closed", |
| 79 | error: null, |
| 80 | hasRunningSubprocess: false, |
| 81 | updatedAt: null, |
| 82 | version: 0, |
| 83 | lifecycleVersion: 0, |
| 84 | }); |
| 85 | |
| 86 | let terminalAttachGeneration = 0; |
| 87 | |
| 88 | /** A reinstalled attach stream must not reuse an old renderer's output cursor. */ |
| 89 | export function nextTerminalAttachSeedState(): TerminalBufferState { |
| 90 | return { |
| 91 | ...EMPTY_TERMINAL_BUFFER_STATE, |
| 92 | output: { |
| 93 | ...EMPTY_TERMINAL_OUTPUT_STATE, |
| 94 | generation: ++terminalAttachGeneration, |
| 95 | }, |
| 96 | }; |
| 97 | } |
| 98 | |
| 99 | function terminalBufferStateFromSnapshot( |
| 100 | snapshot: TerminalSessionSnapshot, |
| 101 | maxBufferBytes: number, |
| 102 | current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, |
| 103 | ): TerminalBufferState { |
| 104 | return { |
| 105 | output: resetOutput(current.output, snapshot.history, maxBufferBytes), |
| 106 | status: snapshot.status, |
| 107 | error: null, |
| 108 | updatedAt: snapshot.updatedAt, |
| 109 | version: current.version + 1, |
| 110 | lifecycleVersion: current.lifecycleVersion, |
| 111 | }; |
| 112 | } |
| 113 | |
| 114 | function latestTimestamp(left: string | null, right: string | null): string | null { |
| 115 | if (left === null) return right; |
| 116 | if (right === null) return left; |
| 117 | return Date.parse(left) >= Date.parse(right) ? left : right; |
| 118 | } |
| 119 | |
| 120 | export function combineTerminalSessionState( |
| 121 | summary: TerminalSummary | null, |
| 122 | buffer: TerminalBufferState, |
| 123 | ): TerminalSessionState { |
| 124 | return { |
| 125 | summary, |
| 126 | output: buffer.output, |
| 127 | status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), |
| 128 | error: buffer.error, |
| 129 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, |
| 130 | updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), |
| 131 | version: buffer.version, |
| 132 | lifecycleVersion: buffer.lifecycleVersion, |
| 133 | }; |
| 134 | } |
| 135 | |
| 136 | export function applyTerminalAttachStreamEvent( |
| 137 | current: TerminalBufferState, |
| 138 | event: TerminalAttachStreamEvent, |
| 139 | maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES, |
| 140 | ): TerminalBufferState { |
| 141 | switch (event.type) { |
| 142 | case "snapshot": |
| 143 | return { |
| 144 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), |
| 145 | lifecycleVersion: |
| 146 | current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, |
| 147 | }; |
| 148 | case "restarted": |
| 149 | return { |
| 150 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), |
| 151 | lifecycleVersion: current.lifecycleVersion + 1, |
| 152 | }; |
| 153 | case "output": |
| 154 | return { |
| 155 | ...current, |
| 156 | output: appendOutput(current.output, event.data, maxBufferBytes), |
| 157 | status: current.status === "closed" ? "running" : current.status, |
| 158 | error: null, |
| 159 | version: current.version + 1, |
| 160 | }; |
| 161 | case "cleared": |
| 162 | return { |
| 163 | ...current, |
| 164 | output: resetOutput(current.output, "", maxBufferBytes), |
| 165 | error: null, |
| 166 | version: current.version + 1, |
| 167 | }; |
| 168 | case "exited": |
| 169 | return { |
| 170 | ...current, |
| 171 | status: "exited", |
| 172 | error: null, |
| 173 | version: current.version + 1, |
| 174 | }; |
| 175 | case "closed": |
| 176 | return { |
| 177 | ...current, |
| 178 | status: "closed", |
| 179 | error: null, |
| 180 | version: current.version + 1, |
| 181 | }; |
| 182 | case "error": |
| 183 | return { |
| 184 | ...current, |
| 185 | status: "error", |
| 186 | error: event.message, |
| 187 | version: current.version + 1, |
| 188 | }; |
| 189 | case "activity": |
| 190 | return current; |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | export function applyTerminalMetadataStreamEvent( |
| 195 | current: ReadonlyArray<TerminalSummary>, |
| 196 | event: TerminalMetadataStreamEvent, |
| 197 | ): ReadonlyArray<TerminalSummary> { |
| 198 | if (event.type === "snapshot") { |
| 199 | return event.terminals; |
| 200 | } |
| 201 | if (event.type === "remove") { |
| 202 | return current.filter( |
| 203 | (terminal) => |
| 204 | terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId, |
| 205 | ); |
| 206 | } |
| 207 | const next = current.filter( |
| 208 | (terminal) => |
| 209 | terminal.threadId !== event.terminal.threadId || |
| 210 | terminal.terminalId !== event.terminal.terminalId, |
| 211 | ); |
| 212 | return [...next, event.terminal]; |
| 213 | } |