[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
211
application/state/sessionRestoreState.ts
Normal file
211
application/state/sessionRestoreState.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
buildSessionRestorePayload,
|
||||
sanitizeSessionRestorePayload,
|
||||
type SessionRestorePayload,
|
||||
} from "../../domain/sessionRestore";
|
||||
|
||||
export type InitialRestoredSessionState = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
};
|
||||
|
||||
export function createInitialRestoredSessionState({
|
||||
restoreEnabled,
|
||||
payload,
|
||||
}: {
|
||||
restoreEnabled: boolean;
|
||||
payload: SessionRestorePayload | null;
|
||||
}): InitialRestoredSessionState {
|
||||
if (!restoreEnabled || !payload) {
|
||||
return {
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
tabOrder: [],
|
||||
activeTabId: "vault",
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSessionRestorePayload(payload);
|
||||
return {
|
||||
sessions: sanitized.sessions,
|
||||
workspaces: sanitized.workspaces,
|
||||
tabOrder: sanitized.tabOrder,
|
||||
activeTabId: sanitized.activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPersistSessionRestoreState(
|
||||
sessions: readonly TerminalSession[],
|
||||
workspaces: readonly Workspace[],
|
||||
tabOrder: readonly string[],
|
||||
): boolean {
|
||||
return sessions.length > 0 || workspaces.length > 0 || tabOrder.length > 0;
|
||||
}
|
||||
|
||||
export function buildPersistableSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
}: {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
now?: number;
|
||||
}): SessionRestorePayload | null {
|
||||
if (!shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) return null;
|
||||
return buildSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAndWriteSessionRestorePayload({
|
||||
restoreEnabled = true,
|
||||
clearOnEmpty = false,
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
storage,
|
||||
}: {
|
||||
restoreEnabled?: boolean;
|
||||
clearOnEmpty?: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
now?: number;
|
||||
storage: {
|
||||
write(payload: SessionRestorePayload): boolean;
|
||||
clear(): void;
|
||||
};
|
||||
}): boolean {
|
||||
if (!restoreEnabled) {
|
||||
storage.clear();
|
||||
return false;
|
||||
}
|
||||
const payload = buildPersistableSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
});
|
||||
if (!payload) {
|
||||
if (clearOnEmpty) {
|
||||
storage.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return storage.write(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how to persist an active-tab change without rebuilding sessions.
|
||||
*
|
||||
* Only the in-memory last full payload is a safe base. Disk/storage may lag
|
||||
* behind live sessions (e.g. connect opens a session and activates its tab
|
||||
* before the debounced full write lands) — never patch from storage alone.
|
||||
*
|
||||
* - full: no trusted cache → caller must rebuild from live state
|
||||
* - noop: cache already has this activeTabId
|
||||
* - patch: cache is trusted; only activeTabId needs updating
|
||||
*/
|
||||
export function resolveSessionRestoreActiveTabWrite({
|
||||
activeTabId,
|
||||
cachedPayload,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
cachedPayload: SessionRestorePayload | null;
|
||||
}): { kind: 'full' } | { kind: 'noop' } | { kind: 'patch'; base: SessionRestorePayload } {
|
||||
if (!cachedPayload) return { kind: 'full' };
|
||||
if (cachedPayload.activeTabId === activeTabId) return { kind: 'noop' };
|
||||
return { kind: 'patch', base: cachedPayload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch only activeTabId on a trusted in-memory full payload.
|
||||
* Call only after resolveSessionRestoreActiveTabWrite returns kind: 'patch'.
|
||||
*
|
||||
* Returns:
|
||||
* - 'patched' when storage was written with a new activeTabId
|
||||
* - 'unchanged' when the cached activeTabId already matches
|
||||
* - 'missing' when there is no trusted base payload (caller should full-write)
|
||||
*
|
||||
* Intentionally does NOT fall back to storage.read() — that can pair a new
|
||||
* activeTabId with stale sessions and corrupt restore after crash.
|
||||
*/
|
||||
export function patchSessionRestoreActiveTabId({
|
||||
activeTabId,
|
||||
now = Date.now(),
|
||||
cachedPayload,
|
||||
storage,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
now?: number;
|
||||
/** In-memory last full payload only — never substitute disk/storage here. */
|
||||
cachedPayload: SessionRestorePayload | null;
|
||||
storage: {
|
||||
write(payload: SessionRestorePayload): boolean;
|
||||
};
|
||||
}): { status: 'patched' | 'unchanged' | 'missing'; payload: SessionRestorePayload | null } {
|
||||
const decision = resolveSessionRestoreActiveTabWrite({ activeTabId, cachedPayload });
|
||||
if (decision.kind === 'full') {
|
||||
return { status: 'missing', payload: null };
|
||||
}
|
||||
if (decision.kind === 'noop') {
|
||||
return { status: 'unchanged', payload: cachedPayload };
|
||||
}
|
||||
const next: SessionRestorePayload = {
|
||||
...decision.base,
|
||||
activeTabId,
|
||||
savedAt: now,
|
||||
};
|
||||
storage.write(next);
|
||||
return { status: 'patched', payload: next };
|
||||
}
|
||||
|
||||
export function mergeSessionRestoreCwd(
|
||||
payload: SessionRestorePayload,
|
||||
sessionId: string,
|
||||
cwd: string | null,
|
||||
): SessionRestorePayload {
|
||||
return sanitizeSessionRestorePayload({
|
||||
...payload,
|
||||
sessions: payload.sessions.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
const { lastCwd: _lastCwd, ...rest } = session;
|
||||
return cwd ? { ...rest, lastCwd: cwd } : rest;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRestoredSessionStatusState<T extends Pick<TerminalSession, "id" | "status" | "restoreState">>(
|
||||
sessions: readonly T[],
|
||||
sessionId: string,
|
||||
status: TerminalSession["status"],
|
||||
): T[] {
|
||||
let changed = false;
|
||||
const next = sessions.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
const shouldClearRestoreState = status === "connecting" || status === "connected";
|
||||
if (session.status === status && (!shouldClearRestoreState || session.restoreState === undefined)) {
|
||||
return session;
|
||||
}
|
||||
changed = true;
|
||||
if (!shouldClearRestoreState) return { ...session, status };
|
||||
const { restoreState: _restoreState, ...rest } = session;
|
||||
return { ...rest, status } as T;
|
||||
});
|
||||
return changed ? next : sessions as T[];
|
||||
}
|
||||
Reference in New Issue
Block a user