mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
655 lines
35 KiB
Markdown
655 lines
35 KiB
Markdown
# Design: Persist and Auto-Restore SSH Sessions Across App Restarts
|
||
|
||
**Issue:** [#985](https://github.com/stablyai/orca/issues/985)
|
||
**Branch:** `Jinwoo-H/persist-ssh-985`
|
||
**Author:** Jinwoo Hong
|
||
**Status:** Draft
|
||
|
||
## Problem
|
||
|
||
When Orca quits (intentionally, via update, or crash), all SSH connections and remote terminal sessions are lost. On restart, users must manually re-open each remote worktree, wait for SSH + relay redeploy, and re-start every remote process — even though the relay daemon on the remote host stays alive during its 5-minute grace window.
|
||
|
||
Three specific gaps:
|
||
|
||
1. **SSH connections are not persisted.** `buildWorkspaceSessionPayload()` in `src/renderer/src/lib/workspace-session.ts` captures terminal tabs, layouts, editor files, and browser tabs but has zero SSH state. `WorkspaceSessionState` has no fields for active connection IDs.
|
||
|
||
2. **Remote PTY session IDs are not persisted.** `reconnectPersistedTerminals()` in `src/renderer/src/store/slices/terminals.ts:1205` explicitly skips SSH-backed repos (`const supportsDeferredReattach = !repo?.connectionId`). Even if the relay keeps PTYs alive, Orca doesn't know their session IDs after restart.
|
||
|
||
3. **Shutdown doesn't serialize SSH state.** Neither `beforeunload` in `App.tsx` nor `will-quit` in `src/main/index.ts` records which SSH targets were connected or what remote PTY session IDs existed.
|
||
|
||
## Scope
|
||
|
||
**In scope (Layer 1 — app-bound lifetime):**
|
||
- Persist active SSH connection targets and remote PTY session IDs at shutdown
|
||
- Auto-reconnect SSH targets on startup
|
||
- Reattach remote PTYs via the relay's existing `pty.attach` RPC
|
||
- Make the relay grace window configurable via settings
|
||
|
||
**Out of scope (Layer 2 — future):**
|
||
- Background daemon owning SSH connections independent of app lifecycle
|
||
- Credential persistence (passphrases/passwords remain session-only)
|
||
|
||
## Existing Architecture
|
||
|
||
### What already works
|
||
|
||
| Component | File | Behavior |
|
||
|-----------|------|----------|
|
||
| Relay grace timer | `src/relay/relay.ts:15,70-76` | Keeps PTYs alive 5 min after client disconnect |
|
||
| Relay `pty.attach` | `src/relay/pty-handler.ts` | Replays buffered output on reattach |
|
||
| In-session reconnect | `src/main/ipc/ssh-relay-helpers.ts` | `reestablishRelayStack()` re-deploys relay, reattaches PTYs for transient drops |
|
||
| SSH target persistence | `src/main/persistence.ts:365-398` | Target configs (host, user, identity) survive restarts |
|
||
| Local PTY reattach | `src/renderer/src/store/slices/terminals.ts:1167-1266` | Daemon-mode local PTYs reattach via `pendingReconnectPtyIdByTabId` |
|
||
| PTY ownership tracking | `src/main/ipc/pty.ts` | `getPtyIdsForConnection()` maps PTY IDs to connection targets (in-memory) |
|
||
| SSH connection retry | `src/main/ssh/ssh-connection.ts` | Exponential backoff reconnection for transient errors |
|
||
|
||
### Connection lifecycle (current)
|
||
|
||
```
|
||
App Start → hydrate repos/worktrees → reconnectPersistedTerminals()
|
||
↓
|
||
skips SSH repos (connectionId truthy)
|
||
↓
|
||
SSH tabs show as disconnected
|
||
```
|
||
|
||
```
|
||
App Shutdown → beforeunload → buildWorkspaceSessionPayload()
|
||
↓
|
||
captures local PTY IDs, tabs, layouts
|
||
does NOT capture SSH connection state
|
||
does NOT capture remote PTY session IDs
|
||
```
|
||
|
||
## Design
|
||
|
||
### 1. Extend WorkspaceSessionState
|
||
|
||
**File:** `src/shared/types.ts`
|
||
|
||
Add two new optional fields:
|
||
|
||
```typescript
|
||
export type WorkspaceSessionState = {
|
||
// ... existing fields ...
|
||
|
||
/** SSH target IDs that were connected at shutdown. Used on startup to
|
||
* auto-reconnect before attempting remote PTY reattach. */
|
||
activeConnectionIdsAtShutdown?: string[]
|
||
|
||
/** Maps tab IDs to their remote relay PTY session IDs. Mirrors
|
||
* pendingReconnectPtyIdByTabId but for SSH-backed terminals. Populated
|
||
* at shutdown from the main-process PTY ownership registry. */
|
||
remoteSessionIdsByTabId?: Record<string, string>
|
||
}
|
||
```
|
||
|
||
**File:** `src/shared/workspace-session-schema.ts`
|
||
|
||
Add corresponding Zod schemas:
|
||
|
||
```typescript
|
||
activeConnectionIdsAtShutdown: z.array(z.string()).optional(),
|
||
remoteSessionIdsByTabId: z.record(z.string(), z.string()).optional(),
|
||
```
|
||
|
||
### 2. Capture SSH state at shutdown
|
||
|
||
**File:** `src/renderer/src/lib/workspace-session.ts`
|
||
|
||
The `WorkspaceSessionSnapshot` type gains two new fields from AppState, and `buildWorkspaceSessionPayload()` populates the new session fields.
|
||
|
||
The renderer already has the data it needs — no IPC required. `tabsByWorktree` contains each tab's `ptyId`, and the renderer knows which worktrees are SSH-backed via `repo.connectionId`. Building the map renderer-side avoids a synchronous IPC call during `beforeunload` (which is fragile: sync IPC blocks the main process and can be dropped under time pressure on quit).
|
||
|
||
**File:** `src/renderer/src/lib/workspace-session.ts`
|
||
|
||
Add `sshConnectionStates` and `repos` / `worktreesByRepo` to the `WorkspaceSessionSnapshot` Pick type, then build both new fields in `buildWorkspaceSessionPayload()`:
|
||
|
||
```typescript
|
||
// Capture active SSH connections.
|
||
// Why: sshConnectionStates is a Map<string, SshConnectionState>, not a plain
|
||
// object. Object.entries() on a Map returns [] — must use Array.from().
|
||
const connectedTargetIds = Array.from(snapshot.sshConnectionStates.entries())
|
||
.filter(([, state]) => state.status === 'connected')
|
||
.map(([targetId]) => targetId)
|
||
|
||
// Build remote PTY session IDs from renderer state.
|
||
// Why: the renderer already has tab.ptyId for every terminal tab and knows
|
||
// which worktrees are SSH-backed via repo.connectionId. Deriving the map
|
||
// here avoids a sync IPC round-trip during beforeunload, which is fragile
|
||
// (can be dropped by Chromium under shutdown time pressure).
|
||
const remoteSessionIdsByTabId: Record<string, string> = {}
|
||
for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) {
|
||
const worktree = Object.values(snapshot.worktreesByRepo)
|
||
.flat()
|
||
.find((w) => w.id === worktreeId)
|
||
const repo = worktree
|
||
? snapshot.repos.find((r) => r.id === worktree.repoId)
|
||
: null
|
||
if (!repo?.connectionId) continue // not SSH-backed
|
||
for (const tab of tabs) {
|
||
if (tab.ptyId) {
|
||
remoteSessionIdsByTabId[tab.id] = tab.ptyId
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
// ... existing fields ...
|
||
activeConnectionIdsAtShutdown: connectedTargetIds.length > 0
|
||
? connectedTargetIds : undefined,
|
||
remoteSessionIdsByTabId: Object.keys(remoteSessionIdsByTabId).length > 0
|
||
? remoteSessionIdsByTabId : undefined,
|
||
}
|
||
```
|
||
|
||
### 3. Auto-reconnect SSH on startup
|
||
|
||
**File:** `src/renderer/src/App.tsx`
|
||
|
||
Insert an SSH reconnect pass between workspace hydration and `reconnectPersistedTerminals()`. Targets are split into two categories:
|
||
|
||
1. **Non-passphrase targets** — reconnected eagerly at startup in parallel.
|
||
2. **Passphrase-protected targets** — deferred until the user focuses an SSH-backed terminal tab.
|
||
|
||
**Why defer passphrase targets:** Eagerly reconnecting passphrase-protected keys would pop credential dialogs before the user has context about which terminal is reconnecting. Stacking multiple passphrase prompts at startup is disorienting and error-prone (wrong passphrase entered for the wrong key). Deferring to tab focus lets the user see the terminal they're about to reconnect and provide the credential with full context.
|
||
|
||
```typescript
|
||
// After repos/worktrees hydrated, before terminal reconnect
|
||
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
|
||
const SSH_RECONNECT_TIMEOUT_MS = 15_000
|
||
|
||
if (connectionIds.length > 0) {
|
||
// Partition targets: eagerly reconnect non-passphrase targets,
|
||
// defer passphrase-protected targets to tab focus.
|
||
// Why: use listTargets (which already has an IPC handler) and index
|
||
// by ID, rather than adding a per-target IPC call. Fetching all
|
||
// targets in one call is simpler and avoids adding a new ssh:getTarget
|
||
// handler that would only be used here.
|
||
const allTargets = await window.api.ssh.listTargets()
|
||
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
|
||
const targets = connectionIds.map((targetId) => {
|
||
const target = targetMap.get(targetId)
|
||
return { targetId, needsPassphrase: target?.lastRequiredPassphrase ?? false }
|
||
})
|
||
|
||
const eagerTargets = targets.filter((t) => !t.needsPassphrase)
|
||
const deferredTargets = targets.filter((t) => t.needsPassphrase)
|
||
|
||
// Store deferred targets so on-demand reconnect can pick them up
|
||
if (deferredTargets.length > 0) {
|
||
actions.setDeferredSshReconnectTargets(
|
||
deferredTargets.map((t) => t.targetId)
|
||
)
|
||
}
|
||
|
||
// Reconnect eager targets in parallel with per-target timeout.
|
||
// Why: a per-target timeout prevents a single unreachable host from
|
||
// blocking the entire startup sequence. 15 seconds is long enough for
|
||
// a typical SSH handshake + relay redeploy over broadband, short enough
|
||
// to avoid the user staring at a frozen app.
|
||
await Promise.allSettled(
|
||
eagerTargets.map(({ targetId }) =>
|
||
Promise.race([
|
||
window.api.ssh.connect({ targetId }),
|
||
new Promise((_, reject) =>
|
||
setTimeout(() => reject(new Error('SSH reconnect timeout')),
|
||
SSH_RECONNECT_TIMEOUT_MS)
|
||
)
|
||
]).catch((err) => {
|
||
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
|
||
})
|
||
)
|
||
)
|
||
}
|
||
|
||
// Now proceed with terminal reconnect (which can now include SSH tabs)
|
||
await actions.reconnectPersistedTerminals(abortController.signal)
|
||
```
|
||
|
||
**On-demand reconnect for deferred (passphrase-protected) targets:**
|
||
|
||
When the user focuses a terminal tab backed by a deferred SSH target, the tab's mount logic checks `deferredSshReconnectTargets`. If the tab's `connectionId` is in that set, it triggers `ssh:connect` for that target, shows the passphrase dialog naturally, and on success proceeds with `pty.attach` for that tab's persisted session ID. The target is removed from the deferred set once connected (or after the user cancels).
|
||
|
||
```typescript
|
||
// In terminal pane mount / tab focus handler
|
||
const deferredTargets = get().deferredSshReconnectTargets ?? []
|
||
if (repo?.connectionId && deferredTargets.includes(repo.connectionId)) {
|
||
// Show reconnecting overlay on this tab (see Section 6)
|
||
try {
|
||
await window.api.ssh.connect({ targetId: repo.connectionId })
|
||
actions.removeDeferredSshReconnectTarget(repo.connectionId)
|
||
// Proceed to pty.attach with the persisted sessionId
|
||
} catch (err) {
|
||
// Leave tab in disconnected state with retry affordance
|
||
}
|
||
}
|
||
```
|
||
|
||
The `ssh:connect` IPC handler already exists and handles the full flow: SSH connect → relay deploy → provider registration. No new main-process code needed for the connect path.
|
||
|
||
**Error handling:** Each connection attempt is independent. Failed connections show the existing `SshDisconnectedDialog` with manual retry. Successful connections proceed to PTY reattach.
|
||
|
||
**Credential prompts:** For eager (non-passphrase) targets, the reconnect completes without user interaction. For deferred targets, the existing `SshPassphraseDialog` flow fires at tab focus — `ssh:credential-request` IPC is already wired.
|
||
|
||
### 4. Enable remote PTY reattach
|
||
|
||
**File:** `src/renderer/src/store/slices/terminals.ts`
|
||
|
||
The current gate at line 1205:
|
||
|
||
```typescript
|
||
const supportsDeferredReattach = !repo?.connectionId
|
||
```
|
||
|
||
Changes to:
|
||
|
||
```typescript
|
||
const supportsDeferredReattach = !repo?.connectionId || hasActiveConnection(repo.connectionId)
|
||
```
|
||
|
||
Where `hasActiveConnection` checks the renderer's `sshConnectionStates` store for `status === 'connected'`.
|
||
|
||
For SSH-backed tabs, the reattach data comes from the new `remoteSessionIdsByTabId` session field instead of `pendingReconnectPtyIdByTabId` (which is populated from the local daemon). The hydration step in `hydrateWorkspaceSession` needs to merge remote session IDs into `pendingReconnectPtyIdByTabId`.
|
||
|
||
**Critical placement constraint:** The existing `pendingReconnectPtyIdByTabId` population loop (lines 1090-1108) is guarded by `if (daemonEnabled)` and explicitly skips SSH-backed repos (`if (repo?.connectionId) { continue }`). The remote session ID merge must run **outside and after** that guard block — SSH PTY reattach does not depend on the local terminal daemon; it uses the relay's `pty.attach` RPC. If the merge is placed inside the `daemonEnabled` block, users without the experimental daemon flag will never have remote session IDs populated, silently breaking the entire reattach feature.
|
||
|
||
**File:** `src/renderer/src/store/slices/terminals.ts` — In `hydrateWorkspaceSession`, after the existing `if (daemonEnabled) { ... }` block (line 1108):
|
||
|
||
```typescript
|
||
// Existing code (unchanged):
|
||
const daemonEnabled = s.settings?.experimentalTerminalDaemon === true
|
||
const pendingReconnectPtyIdByTabId: Record<string, string> = {}
|
||
if (daemonEnabled) {
|
||
// ... existing loop for local daemon session IDs (skips SSH repos) ...
|
||
}
|
||
|
||
// NEW: merge remote PTY session IDs from SSH persistence.
|
||
// Why: this runs outside the daemonEnabled guard because remote PTY reattach
|
||
// uses the relay's pty.attach RPC, not the local terminal daemon. SSH-backed
|
||
// tabs need their session IDs regardless of the experimentalTerminalDaemon
|
||
// setting. The existing loop above correctly skips SSH repos (connectionId
|
||
// check), so there is no overlap — local daemon IDs and remote session IDs
|
||
// are mutually exclusive per-tab.
|
||
const remoteSessionIds = session.remoteSessionIdsByTabId ?? {}
|
||
for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) {
|
||
if (validTabIds.has(tabId)) {
|
||
pendingReconnectPtyIdByTabId[tabId] = sessionId
|
||
}
|
||
}
|
||
```
|
||
|
||
The inner guard in `reconnectPersistedTerminals` (`if (supportsDeferredReattach && tabLevelPtyId)`) works correctly for SSH tabs because `tabLevelPtyId` is now populated by this merge, and `supportsDeferredReattach` is `true` for connected SSH targets.
|
||
|
||
**File:** `src/main/providers/ssh-pty-provider.ts` — Modify `spawn()` to detect sessionId:
|
||
|
||
The current `SshPtyProvider.spawn()` unconditionally calls `pty.spawn` and ignores `opts.sessionId`. This is the critical missing link — without this change, passing a sessionId through `connectPanePty` has no effect on the remote relay.
|
||
|
||
```typescript
|
||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||
// Why: when sessionId is present, the caller is requesting reattach to an
|
||
// existing relay PTY (persisted across app restart). Calling pty.spawn would
|
||
// create a new shell and discard the buffered output the relay kept alive
|
||
// during the grace window. pty.attach replays that buffer instead.
|
||
if (opts.sessionId) {
|
||
try {
|
||
await this.mux.request('pty.attach', {
|
||
id: opts.sessionId,
|
||
cols: opts.cols,
|
||
rows: opts.rows
|
||
})
|
||
return { id: opts.sessionId }
|
||
} catch (err) {
|
||
// Why: pty.attach fails when the relay grace window has elapsed and the
|
||
// PTY no longer exists (relay returns "not found"). Without this catch,
|
||
// the error propagates as an unhandled rejection and the terminal tab
|
||
// stays stuck. Falling through to pty.spawn gives the user a fresh
|
||
// shell, and sessionExpired lets the renderer show a brief toast
|
||
// ("Session expired — new shell started") so the user understands why
|
||
// their scrollback is gone.
|
||
console.warn(`[ssh-pty] pty.attach failed for ${opts.sessionId}, falling back to fresh spawn:`, err)
|
||
}
|
||
}
|
||
|
||
const result = await this.mux.request('pty.spawn', {
|
||
cols: opts.cols,
|
||
rows: opts.rows,
|
||
cwd: opts.cwd,
|
||
env: opts.env
|
||
})
|
||
// Why: sessionExpired is only set when we attempted reattach and it failed.
|
||
// The renderer checks this flag to show the "Session expired" toast. When
|
||
// sessionId was never provided (fresh terminal), this field is omitted.
|
||
return {
|
||
...(result as PtySpawnResult),
|
||
...(opts.sessionId ? { sessionExpired: true } : {})
|
||
}
|
||
}
|
||
```
|
||
|
||
The `PtySpawnResult` type (in `src/main/providers/types.ts`) needs a new optional field:
|
||
|
||
```typescript
|
||
export type PtySpawnResult = {
|
||
id: string
|
||
/** True when the caller requested reattach (sessionId was provided) but the
|
||
* relay PTY was gone (grace window elapsed). The renderer uses this to show
|
||
* a brief "Session expired — new shell started" toast. */
|
||
sessionExpired?: boolean
|
||
}
|
||
```
|
||
|
||
This routes through to the relay's existing `pty.attach` RPC, which replays buffered output via the `pty.replay` notification.
|
||
|
||
**File:** `src/main/ipc/ssh-relay-helpers.ts` — Wire `onReplay` in `wireUpSshPtyEvents()`:
|
||
|
||
The current `wireUpSshPtyEvents()` only wires `onData` and `onExit`. It does **not** wire `onReplay`. After `pty.attach`, the relay sends a `pty.replay` notification with buffered output, but without this wiring the replay data is silently dropped and the terminal renders blank after reattach.
|
||
|
||
Why use a dedicated `pty:replay` IPC channel (not `pty:data`): the renderer's `pty-transport.ts` has a `replayingBufferedData` flag that suppresses xterm auto-replies to embedded terminal query sequences (e.g., `\x1b[6n` for cursor position reports) during replay. If relay replay data arrives via the regular `pty:data` channel, this flag is **not set** — xterm processes the data as normal output and auto-replies leak into the remote shell as stray input, potentially executing unintended commands. A dedicated `pty:replay` channel lets the renderer route replay through the guarded `onReplayData` callback, which suppresses auto-replies.
|
||
|
||
```typescript
|
||
export function wireUpSshPtyEvents(
|
||
ptyProvider: SshPtyProvider,
|
||
getMainWindow: () => BrowserWindow | null
|
||
): void {
|
||
ptyProvider.onData((payload) => {
|
||
const win = getMainWindow()
|
||
if (win && !win.isDestroyed()) {
|
||
win.webContents.send('pty:data', payload)
|
||
}
|
||
})
|
||
// Why: after pty.attach, the relay sends pty.replay with buffered output
|
||
// from the grace window. Without this, reattached terminals render blank.
|
||
// Uses a dedicated pty:replay channel (not pty:data) so the renderer can
|
||
// route it through the replay-guarded onReplayData callback, which
|
||
// suppresses xterm auto-replies to embedded query sequences.
|
||
ptyProvider.onReplay((payload) => {
|
||
const win = getMainWindow()
|
||
if (win && !win.isDestroyed()) {
|
||
win.webContents.send('pty:replay', payload)
|
||
}
|
||
})
|
||
ptyProvider.onExit((payload) => {
|
||
clearProviderPtyState(payload.id)
|
||
deletePtyOwnership(payload.id)
|
||
const win = getMainWindow()
|
||
if (win && !win.isDestroyed()) {
|
||
win.webContents.send('pty:exit', payload)
|
||
}
|
||
})
|
||
}
|
||
```
|
||
|
||
The renderer needs a corresponding `pty:replay` handler in `pty-dispatcher.ts` that routes through the existing `onReplayData` callback (same path used by the local daemon's eager buffer replay). This ensures `replayingBufferedData` is set and xterm auto-replies are suppressed during relay replay.
|
||
|
||
The preload bridge (`src/preload/index.ts`) needs a new `onReplay` binding alongside the existing `onData`:
|
||
|
||
```typescript
|
||
pty: {
|
||
// ... existing bindings ...
|
||
onReplay: (callback: (payload: PtyDataPayload) => void) =>
|
||
ipcRenderer.on('pty:replay', (_event, payload) => callback(payload)),
|
||
}
|
||
```
|
||
|
||
Then in `reconnectPersistedTerminals`, when the SSH connection is confirmed active, the existing `pty.spawn` call in `connectPanePty` receives the `sessionId` which routes through `SshPtyProvider.spawn()` → relay `pty.attach()`, replaying buffered output.
|
||
|
||
**Wait for SSH before reattach:** The key ordering constraint is that `reconnectPersistedTerminals` must run *after* SSH connections are established. The sequential await in App.tsx (step 3) guarantees this.
|
||
|
||
### 5. Configurable relay grace window
|
||
|
||
**File:** `src/shared/ssh-types.ts` — Extend `SshTarget`:
|
||
|
||
```typescript
|
||
export type SshTarget = {
|
||
// ... existing fields ...
|
||
|
||
/** Grace period in seconds before relay shuts down after disconnect.
|
||
* Default: 300 (5 minutes). */
|
||
relayGracePeriodSeconds?: number
|
||
|
||
/** Set to true after a successful connection that triggered a credential
|
||
* prompt (passphrase or password), false after one that didn't. Persisted
|
||
* with the target config so the startup reconnect logic can partition
|
||
* targets into eager (no passphrase) vs deferred (passphrase) without
|
||
* attempting a connection first.
|
||
*
|
||
* Why a persisted flag instead of inspecting the key at startup: detecting
|
||
* whether a key is encrypted requires attempting to load it, which is
|
||
* expensive and may itself trigger an OS keychain prompt. Recording the
|
||
* outcome of the last successful connection is both cheaper and more
|
||
* accurate (it reflects the user's actual auth experience). */
|
||
lastRequiredPassphrase?: boolean
|
||
}
|
||
```
|
||
|
||
**Population:** The `lastRequiredPassphrase` flag is set during `ssh:connect` success handling in the main process. When `SshConnection.connect()` succeeds, the handler checks whether a credential prompt was issued during the attempt (the `onCredentialRequest` callback was invoked). If yes, the flag is set to `true` on the target and persisted; if no, it is set to `false`. This ensures the flag stays up to date as users add/remove passphrases from their keys.
|
||
|
||
**File:** `src/main/ssh/ssh-relay-deploy.ts` — Pass grace time to relay launch:
|
||
|
||
```typescript
|
||
const graceSeconds = target.relayGracePeriodSeconds ?? 300
|
||
const command = `${relayPath} --grace-time ${graceSeconds}`
|
||
```
|
||
|
||
**File:** `src/renderer/src/components/settings/SshTargetForm.tsx` — Add UI field with reasonable bounds (60s–3600s).
|
||
|
||
The relay already accepts `--grace-time` CLI arg (`src/relay/relay.ts:17-29`).
|
||
|
||
## Sequence Diagram
|
||
|
||
### Shutdown
|
||
|
||
```
|
||
Renderer Main Process
|
||
│ │
|
||
├─ beforeunload fires │
|
||
├─ capture terminal buffers │
|
||
├─ build session payload │
|
||
│ (derives remoteSessionIdsByTabId from
|
||
│ Zustand state: tabsByWorktree + repo.connectionId;
|
||
│ derives activeConnectionIdsAtShutdown from
|
||
│ sshConnectionStates — no IPC needed)
|
||
├─ session:set-sync ────────────►├─ write to orca-data.json
|
||
│ ├─ flush()
|
||
└─ window closes └─ SSH connections drop
|
||
relay grace timer starts
|
||
```
|
||
|
||
### Startup (eager targets — no passphrase)
|
||
|
||
```
|
||
Renderer Main Process Remote Relay
|
||
│ │ │
|
||
├─ session:get ─────────────────►├─ read orca-data.json │
|
||
│◄─ session ────────────────────┤ │
|
||
├─ hydrate repos, tabs, layouts │ │
|
||
├─ partition targets: │ │
|
||
│ eager vs deferred │ │
|
||
│ │ │
|
||
├─ ssh:connect(eager1) ─────────►├─ SSH handshake ────────►│
|
||
│ (15s timeout per target) ├─ deploy relay ─────────►├─ READY
|
||
│ ├─ register roots │
|
||
│◄─ connected ──────────────────┤ │
|
||
│ │ │
|
||
├─ reconnectPersistedTerminals() │ │
|
||
│ (SSH tabs no longer skipped │ │
|
||
│ for connected targets) │ │
|
||
│ │ │
|
||
├─ connectPanePty(sessionId) ───►├─ SshPtyProvider.spawn() │
|
||
│ │ detects sessionId │
|
||
│ ├─ pty.attach(id) ──────►├─ replay buffer
|
||
│◄─ pty.replay ─────────────────┤◄─ pty.replay ──────────┤
|
||
│ terminal shows restored │ │
|
||
│ scrollback + live shell │ │
|
||
```
|
||
|
||
### Startup (deferred targets — passphrase-protected)
|
||
|
||
```
|
||
Renderer Main Process Remote Relay
|
||
│ │ │
|
||
│ (passphrase targets stored │ │
|
||
│ in deferredSshReconnectTargets) │
|
||
│ │ │
|
||
├─ user focuses SSH tab ─────────│ │
|
||
│ tab shows "Reconnecting..." │ │
|
||
│ overlay │ │
|
||
├─ ssh:connect(deferred1) ──────►├─ SSH handshake │
|
||
│ ├─ ssh:credential-request │
|
||
│◄─ passphrase dialog ──────────┤ │
|
||
├─ user enters passphrase ──────►├─ auth continues ───────►│
|
||
│ ├─ deploy relay ─────────►├─ READY
|
||
│◄─ connected ──────────────────┤ │
|
||
│ │ │
|
||
├─ connectPanePty(sessionId) ───►├─ pty.attach(id) ──────►├─ replay buffer
|
||
│◄─ pty.replay ─────────────────┤◄─ pty.replay ──────────┤
|
||
│ overlay removed, terminal │ │
|
||
│ shows restored scrollback │ │
|
||
```
|
||
|
||
## Per-Tab Reconnect Status
|
||
|
||
During SSH reconnection and PTY reattach, individual terminal tabs need visual feedback so the user knows what's happening and which tabs are still in progress.
|
||
|
||
**Reconnecting state:** While an SSH target is being reconnected (either eagerly at startup or on-demand at tab focus), each affected terminal tab shows a semi-transparent overlay with a spinner and the text "Reconnecting to [host]...". The terminal content beneath is dimmed but visible — the user can see their previous scrollback through the overlay.
|
||
|
||
**Reattaching state:** After SSH connects but before `pty.attach` completes and replays the buffer, the overlay updates to "Reattaching session...". This is typically brief (<1 second) but visible on high-latency connections.
|
||
|
||
**Success:** The overlay is removed and the terminal receives the replayed buffer. No toast or notification — the terminal being live is sufficient signal.
|
||
|
||
**Failure — target failed:** If the SSH connection fails (timeout, network error, cancelled passphrase), the overlay is replaced with a persistent banner: "SSH connection failed — [reason]" with a "Retry" button. The terminal content remains visible but dimmed and non-interactive.
|
||
|
||
**Failure — session expired:** If SSH connects but `pty.attach` returns `not-found` (grace window elapsed), the tab shows a brief inline message "Session expired — new shell started" and spawns a fresh PTY. The message auto-dismisses after 5 seconds.
|
||
|
||
## Partial Failure Behavior
|
||
|
||
When multiple SSH targets are reconnected at startup, some may succeed and others may fail. The user needs clear per-tab attribution of which connections failed:
|
||
|
||
- **Tabs backed by a successful target:** Proceed to PTY reattach normally. No special indicator — they work as expected.
|
||
- **Tabs backed by a failed target:** Show the persistent failure banner described above. The banner includes the target hostname so the user can identify which remote host is unreachable.
|
||
- **Tabs backed by a deferred (passphrase) target:** Show a static overlay: "Waiting for connection — focus this tab to reconnect". This distinguishes intentionally-deferred tabs from failed tabs.
|
||
|
||
This ensures the user can scan their tab bar and immediately tell which terminals are live, which need attention, and which are waiting for credentials.
|
||
|
||
## Data Flow Diagrams
|
||
|
||
### Path 1: Happy path (restart within grace window)
|
||
|
||
```
|
||
SHUTDOWN:
|
||
Zustand state ──► buildWorkspaceSessionPayload() ──► orca-data.json
|
||
(tabsByWorktree, (derives remoteSessionIdsByTabId, (persisted)
|
||
sshConnectionStates, activeConnectionIdsAtShutdown)
|
||
repos, worktreesByRepo)
|
||
|
||
STARTUP:
|
||
orca-data.json ──► hydrateWorkspaceSession() ──► ssh:connect(eager) ──► reconnectPersistedTerminals()
|
||
(read) (merges remoteSessionIds (SSH + relay up) (pty.attach via SshPtyProvider)
|
||
into pendingReconnectPtyIdByTabId) ──► pty.replay ──► terminal live
|
||
```
|
||
|
||
### Path 2: Nil path (no SSH state persisted)
|
||
|
||
```
|
||
SHUTDOWN:
|
||
No SSH connections active ──► buildWorkspaceSessionPayload() ──► orca-data.json
|
||
(activeConnectionIdsAtShutdown (no SSH fields)
|
||
omitted, remoteSessionIdsByTabId
|
||
omitted)
|
||
|
||
STARTUP:
|
||
orca-data.json ──► hydrateWorkspaceSession() ──► reconnectPersistedTerminals()
|
||
(no SSH fields) (no remote IDs to merge) (SSH tabs still skipped — connectionId
|
||
but no active connection)
|
||
```
|
||
|
||
### Path 3: Grace expired (restart after relay grace window)
|
||
|
||
```
|
||
SHUTDOWN:
|
||
(same as happy path — SSH state persisted to orca-data.json)
|
||
|
||
STARTUP:
|
||
orca-data.json ──► ssh:connect() ──► relay redeploy ──► pty.attach(sessionId)
|
||
(SSH succeeds) (new relay instance; ──► NOT FOUND
|
||
old PTYs gone) ──► spawn fresh PTY
|
||
──► "Session expired" message
|
||
```
|
||
|
||
### Path 4: Error path (SSH connect fails)
|
||
|
||
```
|
||
SHUTDOWN:
|
||
(same as happy path — SSH state persisted to orca-data.json)
|
||
|
||
STARTUP:
|
||
orca-data.json ──► ssh:connect() ──► TIMEOUT/ERROR (15s)
|
||
──► tab shows failure banner
|
||
──► reconnectPersistedTerminals()
|
||
skips tabs for failed targets
|
||
(no active connection)
|
||
──► user retries manually via banner
|
||
```
|
||
|
||
## Migration & Backwards Compatibility
|
||
|
||
- New session fields are optional (`z.optional()`), so older sessions parse without error
|
||
- Older Orca versions ignore unknown fields, so a downgrade doesn't break
|
||
- If `activeConnectionIdsAtShutdown` references a deleted SSH target, the connect call fails gracefully and the tab shows as disconnected
|
||
- If `remoteSessionIdsByTabId` references a PTY that expired (grace window elapsed), `pty.attach` returns `not-found` and the tab spawns a fresh shell
|
||
|
||
## Edge Cases
|
||
|
||
| Scenario | Behavior |
|
||
|----------|----------|
|
||
| Relay grace expired before restart | `pty.attach` fails → tab spawns fresh PTY, shows "Session expired — new shell started" |
|
||
| SSH target deleted between shutdown and startup | `ssh:connect` fails → tabs for that target remain disconnected, user sees reconnect dialog |
|
||
| Network unreachable on startup | `ssh:connect` times out → existing retry/backoff kicks in, shows reconnecting status |
|
||
| Passphrase-protected key | Target deferred to on-demand reconnect at tab focus; passphrase dialog fires in context of the specific terminal |
|
||
| Multiple SSH targets | Non-passphrase targets reconnect eagerly in parallel via `Promise.allSettled`; passphrase targets deferred; partial success is fine |
|
||
| Per-target timeout exceeded | Target treated as failed after 15s; tabs show disconnected state with manual retry affordance |
|
||
| Crash (no beforeunload) | No session captured → same as today (no regression). Periodic save (future improvement) could mitigate |
|
||
| App update with relay version bump | Relay redeploy triggers naturally; old PTYs lost (acceptable — version change implies incompatibility) |
|
||
|
||
## Testing Strategy
|
||
|
||
### Unit Tests
|
||
|
||
- `workspace-session.test.ts`: Verify `buildWorkspaceSessionPayload` includes SSH fields when connections are active and omits them when none are
|
||
- `workspace-session-schema.test.ts`: Verify schema parses sessions with/without new optional fields
|
||
- `terminals.test.ts`: Verify `reconnectPersistedTerminals` processes SSH-backed tabs when connection is active and skips when not
|
||
|
||
### Integration Tests
|
||
|
||
- `store-session-cascades.test.ts`: Extend existing local daemon reattach test suite (lines 660-960) to cover SSH-backed tabs
|
||
- `terminal-restart-persistence.spec.ts`: Add E2E scenario for SSH terminal restore
|
||
|
||
### Manual Test Plan
|
||
|
||
1. Connect to SSH target with 2+ terminal tabs running long processes
|
||
2. Quit Orca
|
||
3. Relaunch within grace window → verify SSH reconnects, terminals show scrollback, processes still running
|
||
4. Quit Orca, wait past grace window, relaunch → verify SSH reconnects, terminals show "session expired" and spawn fresh shells
|
||
5. Quit Orca with passphrase-protected key → verify tabs show "Waiting for connection" overlay, focus a tab → verify passphrase dialog, enter passphrase → verify terminal restores
|
||
6. Kill Orca (force quit) → verify no crash on next launch (no session saved, clean fallback)
|
||
|
||
## Files to Modify
|
||
|
||
| File | Change |
|
||
|------|--------|
|
||
| `src/shared/types.ts` | Add `activeConnectionIdsAtShutdown`, `remoteSessionIdsByTabId` to `WorkspaceSessionState` |
|
||
| `src/shared/ssh-types.ts` | Add `relayGracePeriodSeconds` and `lastRequiredPassphrase` to `SshTarget` |
|
||
| `src/shared/workspace-session-schema.ts` | Add Zod schemas for new fields |
|
||
| `src/main/providers/ssh-pty-provider.ts` | Modify `spawn()` to detect `sessionId`, call `pty.attach` with try/catch fallback to `pty.spawn` |
|
||
| `src/main/providers/types.ts` | Add `sessionExpired?: boolean` to `PtySpawnResult` |
|
||
| `src/main/ipc/ssh-relay-helpers.ts` | Wire `onReplay` in `wireUpSshPtyEvents()` to forward replay data via dedicated `pty:replay` IPC channel |
|
||
| `src/preload/index.ts` | Add `pty.onReplay` binding for the new `pty:replay` IPC channel |
|
||
| `src/renderer/src/lib/pty-dispatcher.ts` | Add `pty:replay` handler that routes through replay-guarded `onReplayData` callback |
|
||
| `src/main/ipc/ssh.ts` | Set `lastRequiredPassphrase` on target after successful `ssh:connect` based on whether credential prompt was triggered |
|
||
| `src/renderer/src/lib/workspace-session.ts` | Add `sshConnectionStates`, `repos`, `worktreesByRepo` to snapshot; build `remoteSessionIdsByTabId` from renderer state |
|
||
| `src/renderer/src/App.tsx` | Add SSH reconnect pass (eager + deferred) before `reconnectPersistedTerminals()` |
|
||
| `src/renderer/src/store/slices/terminals.ts` | Remove SSH skip gate; merge remote session IDs into pending reconnect map; add `deferredSshReconnectTargets` state + actions |
|
||
| `src/main/ssh/ssh-relay-deploy.ts` | Pass configurable grace time to relay |
|
||
| `src/renderer/src/components/settings/SshTargetForm.tsx` | Add grace period setting UI |
|