orca/src/main/persistence.ts

2831 lines
105 KiB
TypeScript

/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration,
load/save, and flush logic in one file so the full storage contract is reviewable
as a unit instead of being scattered across modules. */
import { app, safeStorage } from 'electron'
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
renameSync,
unlinkSync,
copyFileSync,
statSync
} from 'fs'
import { writeFile, rename, mkdir, rm, copyFile } from 'fs/promises'
import { join, dirname } from 'path'
import { homedir } from 'os'
import { randomUUID } from 'node:crypto'
import type {
Automation,
AutomationCreateInput,
AutomationDispatchResult,
AutomationRunOutputSnapshot,
AutomationRun,
AutomationRunTrigger,
AutomationUpdateInput
} from '../shared/automations-types'
import {
latestAutomationOccurrenceAtOrBefore,
nextAutomationOccurrenceAfter
} from '../shared/automation-schedules'
import type {
PersistedState,
Repo,
SparsePreset,
WorktreeMeta,
WorktreeLineage,
GlobalSettings,
OnboardingChecklistState,
OnboardingOutcome,
OnboardingState,
LegacyPaneKeyAliasEntry,
TerminalPaneLayoutNode,
TerminalLayoutSnapshot,
TerminalTab,
WorkspaceSessionState
} from '../shared/types'
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
import type { SshRemotePtyLease, SshTarget } from '../shared/ssh-types'
import { isFolderRepo } from '../shared/repo-kind'
import { getGitUsername } from './git/repo'
import {
getDefaultPersistedState,
getDefaultNotificationSettings,
getDefaultOnboardingState,
getDefaultVoiceSettings,
getDefaultUIState,
getDefaultRepoHookSettings,
getDefaultWorkspaceSession,
normalizeWorktreeCardProperties,
ONBOARDING_FINAL_STEP
} from '../shared/constants'
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
import {
isTerminalLeafId,
makePaneKey,
parseLegacyNumericPaneKey,
parsePaneKey
} from '../shared/stable-pane-id'
import {
setMigrationUnsupportedPty,
setMigrationUnsupportedPtyPersistenceListener
} from './agent-hooks/migration-unsupported-pty-state'
import { agentHookServer } from './agent-hooks/server'
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id'
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
import { normalizeVisibleTaskProviders } from '../shared/task-providers'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
clampWorkspaceBoardOpacity,
normalizeWorkspaceBoardCompact,
normalizePersistedWorkspaceStatuses,
normalizeWorkspaceStatuses
} from '../shared/workspace-statuses'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
return plaintext
}
try {
return safeStorage.encryptString(plaintext).toString('base64')
} catch (err) {
console.error('[persistence] Encryption failed:', err)
return plaintext
}
}
function decrypt(ciphertext: string): string {
if (!ciphertext || !safeStorage.isEncryptionAvailable()) {
return ciphertext
}
try {
return safeStorage.decryptString(Buffer.from(ciphertext, 'base64'))
} catch {
// Why: if decryption fails, it likely means the value was stored as
// plaintext (pre-encryption build) or the OS keychain changed. Fall
// back to the raw string so users don't lose their cookie after upgrade.
console.warn(
'[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.'
)
return ciphertext
}
}
function encryptOptionalSecret(value: string | null | undefined): string | null {
return value ? encrypt(value) : null
}
function decryptOptionalSecret(value: string | null | undefined): string | null {
return value ? decrypt(value) : null
}
// Why: the data-file path must not be a module-level constant. Module-level
// code runs at import time — before configureDevUserDataPath() redirects the
// userData path in index.ts — so a constant would capture the default (non-dev)
// path, causing dev and production instances to share the same file and silently
// overwrite each other.
//
// It also must not be resolved lazily on every call, because app.setName('Orca')
// runs before the Store constructor and would change the resolved path from
// lowercase 'orca' to uppercase 'Orca'. On case-sensitive filesystems (Linux)
// this would look in the wrong directory and lose existing user data.
//
// Solution: index.ts calls initDataPath() right after configureDevUserDataPath()
// but before app.setName(), capturing the correct path at the right moment.
let _dataFile: string | null = null
export function initDataPath(): void {
_dataFile = join(app.getPath('userData'), 'orca-data.json')
}
function getDataFile(): string {
if (!_dataFile) {
// Safety fallback — should not be hit in normal startup.
_dataFile = join(app.getPath('userData'), 'orca-data.json')
}
return _dataFile
}
// Why (issue #1158): keep 5 rolling backups of orca-data.json so a corrupt or
// empty write leaves at least one earlier copy recoverable. Five snapshots at
// >=1-hour spacing cover recent work without churning disk on every debounce.
const BACKUP_COUNT = 5
const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000
function backupPath(dataFile: string, index: number): string {
return `${dataFile}.bak.${index}`
}
function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] {
if (
groupBy === 'none' ||
groupBy === 'workspace-status' ||
groupBy === 'repo' ||
groupBy === 'pr-status'
) {
return groupBy
}
if (groupBy === 'flat') {
return 'none'
}
return getDefaultUIState().groupBy
}
function normalizeSortBy(sortBy: unknown): 'name' | 'smart' | 'recent' | 'repo' {
if (sortBy === 'smart' || sortBy === 'recent' || sortBy === 'repo' || sortBy === 'name') {
return sortBy
}
return getDefaultUIState().sortBy
}
function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function normalizeAutomationRunOutputSnapshot(
value: AutomationRunOutputSnapshot | null | undefined
): AutomationRunOutputSnapshot | null {
if (!value || value.format !== 'plain_text') {
return null
}
const content = typeof value.content === 'string' ? value.content : ''
if (!content.trim()) {
return null
}
return {
format: 'plain_text',
content,
capturedAt:
typeof value.capturedAt === 'number' && Number.isFinite(value.capturedAt)
? value.capturedAt
: Date.now(),
truncated: value.truncated === true
}
}
// Why: old persisted targets predate configHost. Default to label-based lookup
// so imported SSH aliases keep resolving through ssh -G after upgrade.
function normalizeSshTarget(t: SshTarget): SshTarget {
return { ...t, configHost: t.configHost ?? t.label ?? t.host }
}
// Why: shared by load-time merge and the IPC update handler so the same
// strict whitelist guards every entry into onboarding state — arbitrary
// renderer/disk input cannot inject unknown keys or wrong-typed values.
// Returns only validated fields; unknown keys are dropped silently.
// Why: returns Partial<...> with a partial checklist so the IPC update path
// merges over current state without wiping previously-true keys. Invalid
// top-level fields are OMITTED (not coerced to fallbacks) so partial updates
// don't clobber valid persisted state; the load-path caller spreads defaults.
export function sanitizeOnboardingUpdate(
input: unknown
): Partial<Omit<OnboardingState, 'checklist'>> & { checklist?: Partial<OnboardingChecklistState> } {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return {}
}
const raw = input as Record<string, unknown>
const out: Partial<Omit<OnboardingState, 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
} = {}
if ('closedAt' in raw) {
// Why: `typeof raw.closedAt === 'number'` would let NaN/Infinity through;
// JSON.stringify writes those as `null` on save, which silently reverts
// closedAt and re-opens the wizard on next load. Require a finite,
// non-negative timestamp so live state matches what disk can persist.
if (typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) && raw.closedAt >= 0) {
out.closedAt = raw.closedAt
} else if (raw.closedAt === null) {
out.closedAt = null
}
// else: omit — preserve existing persisted value on merge.
}
if ('outcome' in raw) {
const v = raw.outcome
if (v === 'completed' || v === 'dismissed') {
out.outcome = v as OnboardingOutcome
} else if (v === null) {
out.outcome = null
}
// else: omit.
}
if ('lastCompletedStep' in raw) {
const v = raw.lastCompletedStep
if (typeof v === 'number' && Number.isInteger(v) && v >= -1 && v <= ONBOARDING_FINAL_STEP) {
out.lastCompletedStep = v
}
// else: omit.
}
if ('checklist' in raw) {
const rawChecklist = raw.checklist
if (rawChecklist && typeof rawChecklist === 'object' && !Array.isArray(rawChecklist)) {
// Why: copy ONLY caller-sent boolean keys so partial updates (e.g.
// `{ addedRepo: true }`) don't reset other checklist items to false.
const defaults = getDefaultOnboardingState().checklist
const rc = rawChecklist as Record<string, unknown>
const checklist: Partial<OnboardingChecklistState> = {}
for (const key of Object.keys(defaults) as (keyof OnboardingChecklistState)[]) {
if (key in rc && typeof rc[key] === 'boolean') {
checklist[key] = rc[key] as boolean
}
}
out.checklist = checklist
}
}
return out
}
// Why: read a settings field that was removed from the GlobalSettings type
// but still round-trips on disk via the ...parsed.settings spread. One-shot
// use only — for the inline-agents default-on migration's Case B discriminator.
// Delete with the migration in the cleanup release (2+ stable releases after
// _inlineAgentsDefaultedForAllUsers ships).
function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean {
return (
(parsed?.settings as { experimentalAgentDashboard?: boolean } | undefined)
?.experimentalAgentDashboard === true
)
}
function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | undefined {
return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick
}
function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null {
if (!value || typeof value !== 'object') {
return null
}
const raw = value as Partial<SshRemotePtyLease>
if (typeof raw.targetId !== 'string' || typeof raw.ptyId !== 'string') {
return null
}
const state = raw.state ?? 'detached'
if (!['attached', 'detached', 'terminated', 'expired'].includes(state)) {
return null
}
const now = Date.now()
return {
targetId: raw.targetId,
ptyId: raw.ptyId,
...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}),
...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}),
...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}),
state,
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now,
updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now,
...(typeof raw.lastAttachedAt === 'number' ? { lastAttachedAt: raw.lastAttachedAt } : {}),
...(typeof raw.lastDetachedAt === 'number' ? { lastDetachedAt: raw.lastDetachedAt } : {})
}
}
type LayoutLeafNormalization = {
snapshot: TerminalLayoutSnapshot
changed: boolean
leafIdByInputLeafId: Map<string, string>
}
function collectLayoutLeafCounts(
node: TerminalPaneLayoutNode,
counts: Map<string, number> = new Map()
): Map<string, number> {
if (node.type === 'leaf') {
counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1)
return counts
}
collectLayoutLeafCounts(node.first, counts)
collectLayoutLeafCounts(node.second, counts)
return counts
}
function collectLayoutLeafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined): string[] {
if (!node) {
return []
}
if (node.type === 'leaf') {
return [node.leafId]
}
return [...collectLayoutLeafIdsInOrder(node.first), ...collectLayoutLeafIdsInOrder(node.second)]
}
function firstLayoutLeafId(node: TerminalPaneLayoutNode | null): string | null {
if (!node) {
return null
}
return node.type === 'leaf' ? node.leafId : firstLayoutLeafId(node.first)
}
function layoutContainsLeafId(node: TerminalPaneLayoutNode | null, leafId: string): boolean {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return layoutContainsLeafId(node.first, leafId) || layoutContainsLeafId(node.second, leafId)
}
function cloneLayoutNode(node: TerminalPaneLayoutNode): TerminalPaneLayoutNode {
if (node.type === 'leaf') {
return { type: 'leaf', leafId: node.leafId }
}
return {
...node,
first: cloneLayoutNode(node.first),
second: cloneLayoutNode(node.second)
}
}
function cloneLayoutWithLeafIds(
node: TerminalPaneLayoutNode,
leafIdByInputLeafId: Map<string, string>,
duplicatedInputLeafIds: Set<string>
): TerminalPaneLayoutNode {
if (node.type === 'leaf') {
return {
type: 'leaf',
leafId: duplicatedInputLeafIds.has(node.leafId)
? randomUUID()
: (leafIdByInputLeafId.get(node.leafId) ?? randomUUID())
}
}
return {
...node,
first: cloneLayoutWithLeafIds(node.first, leafIdByInputLeafId, duplicatedInputLeafIds),
second: cloneLayoutWithLeafIds(node.second, leafIdByInputLeafId, duplicatedInputLeafIds)
}
}
function remapLeafRecordForPersistence(
source: Record<string, string> | undefined,
leafIdByInputLeafId: Map<string, string>,
duplicatedInputLeafIds: Set<string>
): Record<string, string> | undefined {
if (!source) {
return undefined
}
const next: Record<string, string> = {}
for (const [leafId, value] of Object.entries(source)) {
if (duplicatedInputLeafIds.has(leafId)) {
continue
}
const nextLeafId = leafIdByInputLeafId.get(leafId)
if (nextLeafId) {
next[nextLeafId] = value
}
}
return Object.keys(next).length > 0 ? next : undefined
}
function leafRecordEquivalent(
left: Record<string, string> | undefined,
right: Record<string, string> | undefined
): boolean {
const leftEntries = Object.entries(left ?? {})
const rightRecord = right ?? {}
if (leftEntries.length !== Object.keys(rightRecord).length) {
return false
}
return leftEntries.every(([key, value]) => rightRecord[key] === value)
}
function preserveMissingLeafRecordEntries(
priorRecord: Record<string, string> | undefined,
incomingRecord: Record<string, string> | undefined,
liveLeafIds: Set<string>
): Record<string, string> | undefined {
const preserved = Object.fromEntries(
Object.entries(priorRecord ?? {}).filter(
([leafId]) => liveLeafIds.has(leafId) && incomingRecord?.[leafId] === undefined
)
)
const next = { ...preserved, ...incomingRecord }
return Object.keys(next).length > 0 ? next : undefined
}
function findWorktreeIdForTab(session: WorkspaceSessionState, tabId: string): string | undefined {
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
if (tabs.some((tab) => tab.id === tabId)) {
return worktreeId
}
}
return undefined
}
type PaneIdentityMigrationEntries = {
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
}
function collectMigrationUnsupportedPtyEntries(args: {
session: WorkspaceSessionState
tabId: string
inputLayout: TerminalLayoutSnapshot
normalizedLayout: TerminalLayoutSnapshot
leafIdByInputLeafId: Map<string, string>
}): PaneIdentityMigrationEntries {
const worktreeId = findWorktreeIdForTab(args.session, args.tabId)
const tab = worktreeId
? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId)
: undefined
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const registeredLegacyPaneKeys = new Set<string>()
const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0
const fallbackPtyId =
!hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined
const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => {
if (!isTerminalLeafId(leafId)) {
return false
}
let paneKey: string
try {
paneKey = makePaneKey(args.tabId, leafId)
} catch {
return false
}
const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1]
if (!numeric) {
return false
}
// Why: persisted PaneManager ids are 1-based. A zero-based alias in split
// layouts would make tab:1 ambiguous and can route the first pane to the second.
const legacyPaneKey = `${args.tabId}:${numeric}`
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId)
registeredLegacyPaneKeys.add(legacyPaneKey)
if (ptyId) {
legacyPaneKeyAliasEntries.push({
ptyId,
legacyPaneKey,
stablePaneKey: paneKey,
updatedAt: Date.now()
})
return true
}
return false
}
const inputLeafIds = new Set([
...collectLayoutLeafIdsInOrder(args.inputLayout.root),
...Object.keys(args.inputLayout.ptyIdsByLeafId ?? {})
])
for (const inputLeafId of inputLeafIds) {
if (isTerminalLeafId(inputLeafId)) {
continue
}
const leafId = args.leafIdByInputLeafId.get(inputLeafId)
if (leafId) {
registerLegacyAlias(
inputLeafId,
leafId,
args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId
)
}
}
if (tab?.ptyId && !hasLeafPtyBindings) {
const fallbackLeafId =
args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root)
if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) {
const paneKey = makePaneKey(args.tabId, fallbackLeafId)
for (const legacyPaneKey of [`${args.tabId}:0`, `${args.tabId}:1`]) {
if (registeredLegacyPaneKeys.has(legacyPaneKey)) {
continue
}
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId)
legacyPaneKeyAliasEntries.push({
ptyId: tab.ptyId,
legacyPaneKey,
stablePaneKey: paneKey,
updatedAt: Date.now()
})
}
}
}
// Why: legacy numeric pane keys are now bridged by aliases instead of
// persisted as restart-required rows. Existing saved rows are pruned during
// normalizePersistedPaneIdentityState.
return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries }
}
function legacyMigrationUnsupportedRowsToAliasEntries(
entries: MigrationUnsupportedPtyEntry[]
): LegacyPaneKeyAliasEntry[] {
const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter(
(entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey)
)
const entriesByTabId = new Map<string, MigrationUnsupportedPtyEntry[]>()
for (const entry of normalizedEntries) {
const tabId = entry.tabId
if (!tabId) {
continue
}
entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry])
}
const aliasEntries: LegacyPaneKeyAliasEntry[] = []
for (const [tabId, tabEntries] of entriesByTabId) {
if (tabEntries.length !== 1) {
continue
}
const [entry] = tabEntries
if (!entry.paneKey) {
continue
}
// Why: pre-stable dev/RC migration rows did not store the old numeric
// key. Only synthesize the single-pane aliases when the row is unambiguous
// for its tab; split rows need layout-derived aliases instead of a guess.
for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) {
aliasEntries.push({
ptyId: entry.ptyId,
legacyPaneKey,
stablePaneKey: entry.paneKey,
updatedAt: entry.updatedAt
})
}
}
return aliasEntries
}
function normalizeTerminalLayoutSnapshotForPersistence(
snapshot: TerminalLayoutSnapshot,
preferredLayout?: TerminalLayoutSnapshot
): LayoutLeafNormalization {
let inputSnapshot = snapshot
let changed = false
if (!inputSnapshot.root) {
if (!preferredLayout?.root) {
return { snapshot, changed: false, leafIdByInputLeafId: new Map() }
}
const root = cloneLayoutNode(preferredLayout.root)
const rootLeafIds = new Set(collectLayoutLeafIdsInOrder(root))
const activeLeafId =
(inputSnapshot.activeLeafId && rootLeafIds.has(inputSnapshot.activeLeafId)
? inputSnapshot.activeLeafId
: null) ??
(preferredLayout.activeLeafId && rootLeafIds.has(preferredLayout.activeLeafId)
? preferredLayout.activeLeafId
: null) ??
firstLayoutLeafId(root)
const expandedLeafId =
(inputSnapshot.expandedLeafId && rootLeafIds.has(inputSnapshot.expandedLeafId)
? inputSnapshot.expandedLeafId
: null) ??
(preferredLayout.expandedLeafId && rootLeafIds.has(preferredLayout.expandedLeafId)
? preferredLayout.expandedLeafId
: null)
inputSnapshot = { ...inputSnapshot, root, activeLeafId, expandedLeafId }
// Why: a debounced renderer writer can still hold the createTab-era empty
// layout after persistPtyBinding has already sync-flushed the UUID root.
changed = true
}
const inputRoot = inputSnapshot.root
if (!inputRoot) {
return { snapshot, changed: false, leafIdByInputLeafId: new Map() }
}
const counts = collectLayoutLeafCounts(inputRoot)
const duplicatedInputLeafIds = new Set(
Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([leafId]) => leafId)
)
const inputLeafIdsInOrder = collectLayoutLeafIdsInOrder(inputRoot)
const preferredLeafIdsInOrder = collectLayoutLeafIdsInOrder(preferredLayout?.root)
const usePreferredLeafIds = preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length
const leafIdByInputLeafId = new Map<string, string>()
for (const [index, leafId] of inputLeafIdsInOrder.entries()) {
const count = counts.get(leafId) ?? 0
if (count !== 1 || leafIdByInputLeafId.has(leafId)) {
changed = true
continue
}
if (isTerminalLeafId(leafId)) {
leafIdByInputLeafId.set(leafId, leafId)
continue
}
changed = true
const preferredLeafId = usePreferredLeafIds ? preferredLeafIdsInOrder[index] : undefined
leafIdByInputLeafId.set(
leafId,
preferredLeafId && isTerminalLeafId(preferredLeafId) ? preferredLeafId : randomUUID()
)
}
const root = changed
? cloneLayoutWithLeafIds(inputRoot, leafIdByInputLeafId, duplicatedInputLeafIds)
: inputRoot
const activeLeafId =
inputSnapshot.activeLeafId && !duplicatedInputLeafIds.has(inputSnapshot.activeLeafId)
? (leafIdByInputLeafId.get(inputSnapshot.activeLeafId) ?? firstLayoutLeafId(root))
: inputSnapshot.activeLeafId === null
? null
: firstLayoutLeafId(root)
const expandedLeafId =
inputSnapshot.expandedLeafId && !duplicatedInputLeafIds.has(inputSnapshot.expandedLeafId)
? (leafIdByInputLeafId.get(inputSnapshot.expandedLeafId) ?? null)
: null
const ptyIdsByLeafId = remapLeafRecordForPersistence(
inputSnapshot.ptyIdsByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const buffersByLeafId = remapLeafRecordForPersistence(
inputSnapshot.buffersByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const titlesByLeafId = remapLeafRecordForPersistence(
inputSnapshot.titlesByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const recordsChanged =
!leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) ||
!leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) ||
!leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId)
const metadataChanged =
activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId
if (!changed && !recordsChanged && !metadataChanged) {
return { snapshot, changed: false, leafIdByInputLeafId }
}
const {
ptyIdsByLeafId: _oldPtyIdsByLeafId,
buffersByLeafId: _oldBuffersByLeafId,
titlesByLeafId: _oldTitlesByLeafId,
...snapshotWithoutLeafRecords
} = inputSnapshot
return {
snapshot: {
...snapshotWithoutLeafRecords,
root,
activeLeafId,
expandedLeafId,
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}),
...(buffersByLeafId ? { buffersByLeafId } : {}),
...(titlesByLeafId ? { titlesByLeafId } : {})
},
changed: true,
leafIdByInputLeafId
}
}
function normalizeWorkspaceSessionPaneIdentities(
session: WorkspaceSessionState,
priorLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
): {
session: WorkspaceSessionState
changed: boolean
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
} {
let changed = false
const leafIdByInputLeafIdByTabId = new Map<string, Map<string, string>>()
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const normalized = normalizeTerminalLayoutSnapshotForPersistence(
layout,
priorLayoutsByTabId[tabId]
)
terminalLayoutsByTabId[tabId] = normalized.snapshot
leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId)
const migrationEntries = collectMigrationUnsupportedPtyEntries({
session,
tabId,
inputLayout: layout,
normalizedLayout: normalized.snapshot,
leafIdByInputLeafId: normalized.leafIdByInputLeafId
})
migrationUnsupportedEntries.push(...migrationEntries.migrationUnsupportedEntries)
legacyPaneKeyAliasEntries.push(...migrationEntries.legacyPaneKeyAliasEntries)
const leafIdByPtyId = new Map<string, string>()
const duplicatePtyIds = new Set<string>()
for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) {
if (duplicatePtyIds.has(ptyId)) {
continue
}
if (leafIdByPtyId.has(ptyId)) {
leafIdByPtyId.delete(ptyId)
duplicatePtyIds.add(ptyId)
continue
}
leafIdByPtyId.set(ptyId, leafId)
}
leafIdByPtyIdByTabId.set(tabId, leafIdByPtyId)
changed ||= normalized.changed
}
return {
session: changed ? { ...session, terminalLayoutsByTabId } : session,
changed,
leafIdByInputLeafIdByTabId,
leafIdByPtyIdByTabId,
migrationUnsupportedEntries,
legacyPaneKeyAliasEntries
}
}
function remapSshRemotePtyLeaseLeafIds(
leases: SshRemotePtyLease[],
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>,
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
): { leases: SshRemotePtyLease[]; changed: boolean } {
let changed = false
const nextLeases = leases.map((lease) => {
if (lease.leafId === undefined || isTerminalLeafId(lease.leafId)) {
return lease
}
const remappedLeafId = lease.tabId
? leafIdByInputLeafIdByTabId.get(lease.tabId)?.get(lease.leafId)
: undefined
const leafIdForPty = lease.tabId
? leafIdByPtyIdByTabId.get(lease.tabId)?.get(lease.ptyId)
: undefined
changed = true
const nextLeafId = remappedLeafId ?? leafIdForPty
if (nextLeafId) {
return { ...lease, leafId: nextLeafId }
}
const next = { ...lease }
// Why: unmatched legacy leaf ids are ambiguous after migration; do not
// re-persist them as durable pane identity.
delete next.leafId
return next
})
return { leases: nextLeases, changed }
}
function normalizePersistedPaneIdentityState(state: PersistedState): {
state: PersistedState
changed: boolean
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
} {
const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {})
const remappedLeases = remapSshRemotePtyLeaseLeafIds(
state.sshRemotePtyLeases ?? [],
normalizedSession.leafIdByInputLeafIdByTabId,
normalizedSession.leafIdByPtyIdByTabId
)
const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([
...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries),
...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []),
...normalizedSession.legacyPaneKeyAliasEntries
])
const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys(
state.ui?.acknowledgedAgentsByPaneKey,
normalizedSession.leafIdByInputLeafIdByTabId
)
const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual(
state.migrationUnsupportedPtyEntries ?? [],
mergedMigrationUnsupportedEntries
)
const legacyAliasesChanged = !legacyPaneKeyAliasEntriesEqual(
state.legacyPaneKeyAliasEntries ?? [],
mergedLegacyPaneKeyAliasEntries
)
if (
!normalizedSession.changed &&
!remappedLeases.changed &&
!migrationUnsupportedChanged &&
!legacyAliasesChanged &&
!remappedAcknowledgements.changed
) {
return {
state,
changed: false,
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
}
}
return {
state: {
...state,
workspaceSession: normalizedSession.session,
sshRemotePtyLeases: remappedLeases.leases,
migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries,
...(remappedAcknowledgements.changed
? {
ui: {
...state.ui,
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
}
}
: {})
},
changed: true,
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
}
}
function remapAcknowledgedAgentPaneKeys(
acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
if (!acknowledgements || Object.keys(acknowledgements).length === 0) {
return { acknowledgements, changed: false }
}
let changed = false
const next: NonNullable<PersistedState['ui']['acknowledgedAgentsByPaneKey']> = {}
const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => {
const existing = next[paneKey]
next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt)
}
for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) {
const parsed = parsePaneKey(paneKey)
if (parsed) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
const delimiter = paneKey.indexOf(':')
if (delimiter <= 0 || delimiter === paneKey.length - 1) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
const tabId = paneKey.slice(0, delimiter)
const legacyLeafId = paneKey.slice(delimiter + 1)
const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
try {
// Why: UI acks are keyed by paneKey just like hook rows. When a legacy
// numeric/pane:* leaf is promoted to a UUID, carry the read marker over
// so already-seen Activity/sidebar rows do not come back unread.
setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt)
changed = true
} catch {
setAcknowledgement(paneKey, acknowledgedAt)
}
}
return { acknowledgements: next, changed }
}
function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsupportedPtyEntry[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((entry): entry is MigrationUnsupportedPtyEntry => {
if (!entry || typeof entry !== 'object') {
return false
}
const candidate = entry as Partial<MigrationUnsupportedPtyEntry>
return (
typeof candidate.ptyId === 'string' &&
candidate.ptyId.length > 0 &&
(candidate.worktreeId === undefined || typeof candidate.worktreeId === 'string') &&
(candidate.tabId === undefined || typeof candidate.tabId === 'string') &&
(candidate.leafId === undefined || isTerminalLeafId(candidate.leafId)) &&
(candidate.paneKey === undefined || typeof candidate.paneKey === 'string') &&
candidate.reason === 'legacy-numeric-pane-key' &&
(candidate.source === 'local' || candidate.source === 'ssh') &&
Number.isFinite(candidate.updatedAt)
)
})
}
function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasEntry[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((entry): entry is LegacyPaneKeyAliasEntry => {
if (!entry || typeof entry !== 'object') {
return false
}
const candidate = entry as Partial<LegacyPaneKeyAliasEntry>
if (
typeof candidate.ptyId !== 'string' ||
candidate.ptyId.trim().length === 0 ||
typeof candidate.legacyPaneKey !== 'string' ||
typeof candidate.stablePaneKey !== 'string' ||
!Number.isFinite(candidate.updatedAt)
) {
return false
}
const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey)
const stable = parsePaneKey(candidate.stablePaneKey)
return Boolean(legacy && stable && legacy.tabId === stable.tabId)
})
}
function mergeLegacyPaneKeyAliasEntries(
entries: LegacyPaneKeyAliasEntry[]
): LegacyPaneKeyAliasEntry[] {
const byLegacyPaneKey = new Map<string, LegacyPaneKeyAliasEntry>()
for (const entry of normalizeLegacyPaneKeyAliasEntries(entries)) {
const existing = byLegacyPaneKey.get(entry.legacyPaneKey)
if (!existing || existing.updatedAt <= entry.updatedAt) {
byLegacyPaneKey.set(entry.legacyPaneKey, entry)
}
}
return [...byLegacyPaneKey.values()]
}
function legacyPaneKeyAliasEntriesEqual(
left: LegacyPaneKeyAliasEntry[],
right: LegacyPaneKeyAliasEntry[]
): boolean {
if (left.length !== right.length) {
return false
}
const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry]))
return left.every((entry) => {
const other = rightByLegacyPaneKey.get(entry.legacyPaneKey)
return other ? JSON.stringify(entry) === JSON.stringify(other) : false
})
}
function migrationUnsupportedEntriesEqual(
left: MigrationUnsupportedPtyEntry[],
right: MigrationUnsupportedPtyEntry[]
): boolean {
if (left.length !== right.length) {
return false
}
const rightByPtyId = new Map(right.map((entry) => [entry.ptyId, entry]))
return left.every((entry) => {
const other = rightByPtyId.get(entry.ptyId)
return other ? JSON.stringify(entry) === JSON.stringify(other) : false
})
}
function createMinimalPersistedTerminalTab(args: {
worktreeId: string
tabId: string
ptyId: string
existingTabCount: number
}): TerminalTab {
const ordinal = args.existingTabCount + 1
const defaultTitle = `Terminal ${ordinal}`
return {
id: args.tabId,
ptyId: args.ptyId,
worktreeId: args.worktreeId,
title: defaultTitle,
defaultTitle,
customTitle: null,
color: null,
sortOrder: args.existingTabCount,
createdAt: Date.now(),
pendingActivationSpawn: true
}
}
function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSessionState {
return structuredClone(session)
}
export class Store {
private state: PersistedState
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
private writeGeneration = 0
private gitUsernameCache = new Map<string, string>()
private loadNeedsSave = false
constructor() {
const loaded = this.load()
const normalized = normalizePersistedPaneIdentityState(loaded)
this.state = normalized.state
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
for (const entry of normalized.legacyPaneKeyAliasEntries) {
agentHookServer.registerPaneKeyAlias(
entry.legacyPaneKey,
entry.stablePaneKey,
entry.ptyId,
entry.updatedAt,
{ overwriteExisting: false }
)
}
setMigrationUnsupportedPtyPersistenceListener((entries) => {
this.state.migrationUnsupportedPtyEntries = entries
this.scheduleSave()
})
agentHookServer.setPaneKeyAliasPersistenceListener((entries) => {
this.state.legacyPaneKeyAliasEntries = entries
this.scheduleSave()
})
if (normalized.changed || this.loadNeedsSave) {
// Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at
// the main persistence boundary so older renderer writes cannot revive them.
// Other one-shot load migrations also set loadNeedsSave to persist their
// guard flags before the next restart.
this.scheduleSave()
}
}
// Why (issue #1158): debounced writes fire as often as every 300ms during
// active use. The backup ring should capture meaningfully different moments,
// not five near-identical snapshots from one burst of store updates.
private shouldRotateBackups(now: number, dataFile: string): boolean {
try {
const mtime = statSync(backupPath(dataFile, 0)).mtimeMs
return now - mtime >= BACKUP_MIN_INTERVAL_MS
} catch {
return true
}
}
// Why: rotate oldest to discarded and shift .bak.i to .bak.i+1 by rename;
// then copy the current data file to .bak.0 so load() has a JSON recovery
// source even if a later primary write is truncated or corrupted.
private async rotateBackupsAsync(dataFile: string): Promise<void> {
if (!existsSync(dataFile)) {
return
}
await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => {
if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[persistence] Failed to remove oldest backup:', err)
}
})
for (let i = BACKUP_COUNT - 2; i >= 0; i--) {
const src = backupPath(dataFile, i)
const dst = backupPath(dataFile, i + 1)
if (existsSync(src)) {
await rename(src, dst).catch((err) => {
console.error('[persistence] Failed to rotate backup', src, '->', dst, err)
})
}
}
await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => {
console.error('[persistence] Failed to snapshot current file to .bak.0:', err)
})
}
private rotateBackupsSync(dataFile: string): void {
if (!existsSync(dataFile)) {
return
}
try {
unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1))
} catch (err) {
if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[persistence] Failed to remove oldest backup:', err)
}
}
for (let i = BACKUP_COUNT - 2; i >= 0; i--) {
const src = backupPath(dataFile, i)
const dst = backupPath(dataFile, i + 1)
if (existsSync(src)) {
try {
renameSync(src, dst)
} catch (err) {
console.error('[persistence] Failed to rotate backup', src, '->', dst, err)
}
}
}
try {
copyFileSync(dataFile, backupPath(dataFile, 0))
} catch (err) {
console.error('[persistence] Failed to snapshot current file to .bak.0:', err)
}
}
private restoreFromBackup(dataFile: string): boolean {
for (let i = 0; i < BACKUP_COUNT; i++) {
const path = backupPath(dataFile, i)
if (!existsSync(path)) {
continue
}
try {
const raw = readFileSync(path, 'utf-8')
JSON.parse(raw)
mkdirSync(dirname(dataFile), { recursive: true })
writeFileSync(dataFile, raw, 'utf-8')
console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`)
return true
} catch (err) {
console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err)
}
}
return false
}
private load(allowBackupRecovery = true): PersistedState {
// Capture once, at the top: this is the unambiguous "has the user run
// Orca before?" signal used by the telemetry cohort migration below.
// Field-based inference (e.g., `settings.telemetry` presence) does not
// work on the telemetry release itself — `telemetry` is new here, so it
// would be absent on every pre-telemetry install and misclassify existing
// users as fresh, flipping them to default-on in violation of the
// social contract we installed them under.
const dataFile = getDataFile()
const fileExistedOnLoad = existsSync(dataFile)
let result: PersistedState | null = null
try {
if (fileExistedOnLoad) {
const raw = readFileSync(dataFile, 'utf-8')
const parsed = JSON.parse(raw) as PersistedState
// Why: opencodeSessionCookie is stored encrypted on disk via safeStorage.
// Decrypt at the load boundary so the rest of the app sees plaintext.
if (parsed.settings?.opencodeSessionCookie) {
parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie)
}
if (parsed.ui?.browserKagiSessionLink) {
parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink)
}
// Merge with defaults in case new fields were added
const defaults = getDefaultPersistedState(homedir())
// Why: before the layout-aware 'auto' mode shipped (issue #903),
// terminalMacOptionAsAlt defaulted to 'true' globally. That silently
// broke Option-layer characters (@ on Turkish via Option+Q, @ on
// German via Option+L, € on French via Option+E) for non-US users.
// We can't distinguish a persisted 'true' that the user chose
// explicitly from one they inherited from the old default — so on
// first launch after upgrade, flip 'true' back to 'auto' and let
// the renderer's keyboard-layout probe pick the right value per
// layout. US users land on 'true' via detection (no change); non-US
// users land on 'false' (correct). 'false'/'left'/'right' are
// definitionally explicit choices (they never matched the old
// default) so we carry those forward unchanged. The migrated flag
// guards against re-running this on subsequent launches.
const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt
const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true
const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated
? (rawOptionAsAlt ?? 'auto')
: rawOptionAsAlt === undefined || rawOptionAsAlt === 'true'
? 'auto'
: rawOptionAsAlt
const floatingTerminalDefaultedForAllUsers =
parsed.settings?.floatingTerminalDefaultedForAllUsers === true
// Why: early floating-terminal builds persisted the old off-by-default
// value into user profiles. Flip only unmigrated profiles so a later
// deliberate opt-out still survives reload.
const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers
? (parsed.settings?.floatingTerminalEnabled ?? true)
: true
const experimentalActivityDefaultedOffForAllUsers =
parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true
// Why: the Agents view moved back behind Experimental. Flip every
// pre-migration profile off once, then preserve future user opt-ins.
const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers
? (parsed.settings?.experimentalActivity ?? false)
: false
result = {
...defaults,
...parsed,
worktreeLineageById: parsed.worktreeLineageById ?? {},
settings: {
...defaults.settings,
...parsed.settings,
// Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry
// the old persisted flag forward once so enabled users don't lose it.
experimentalPet:
parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
terminalMacOptionAsAlt: migratedOptionAsAlt,
terminalMacOptionAsAltMigrated: true,
floatingTerminalEnabled: migratedFloatingTerminalEnabled,
floatingTerminalDefaultedForAllUsers: true,
terminalQuickCommands: normalizeTerminalQuickCommands(
parsed.settings?.terminalQuickCommands
),
visibleTaskProviders: normalizeVisibleTaskProviders(
parsed.settings?.visibleTaskProviders
),
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications),
notifications: {
...getDefaultNotificationSettings(),
...parsed.settings?.notifications
},
voice: {
...getDefaultVoiceSettings(),
...parsed.settings?.voice
}
},
// Why: 'recent' used to mean the weighted smart sort. One-shot
// migration moves it to 'smart'; the flag prevents re-firing after
// a user intentionally selects the new last-activity 'recent' sort.
// Gate on the *raw* persisted value, not the normalized one: the
// default sortBy is now 'recent', so a fresh install with no
// persisted sortBy would otherwise be mis-migrated to 'smart'.
ui: (() => {
const rawSort = parsed.ui?.sortBy
const sort = normalizeSortBy(rawSort)
const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent'
const workspaceStatusesDefaultOrderMigrated =
parsed.ui?._workspaceStatusesDefaultOrderMigrated === true
// Why: the default workflow changed to Done -> Review -> Progress -> Todo.
// Only exact legacy default payloads are migrated; users who
// customized status labels, colors, icons, or order keep theirs.
const workspaceStatusesDefaultWorkflowMigrated =
parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true
// Why: visual migration has its own guard so later user choices
// of valid legacy color/icon IDs are preserved by runtime writes.
const workspaceStatusesDefaultVisualsMigrated =
parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true
const workspaceStatuses = normalizePersistedWorkspaceStatuses(
parsed.ui?.workspaceStatuses,
{
migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated,
repairReorderedDefaultStatuses: !workspaceStatusesDefaultOrderMigrated,
migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated
}
)
if (
!workspaceStatusesDefaultOrderMigrated ||
!workspaceStatusesDefaultWorkflowMigrated ||
!workspaceStatusesDefaultVisualsMigrated
) {
this.loadNeedsSave = true
}
// Why: the 'inline-agents' card property was added after the
// feature shipped behind an experimental toggle. Now that the
// feature is default-on for everyone, every existing user needs
// 'inline-agents' appended to their persisted
// worktreeCardProperties on first load after upgrade so the
// inline agent rows render without further opt-in. A flag
// prevents re-firing so a deliberate uncheck from the Workspaces
// view options menu sticks across restarts.
//
// TRAP — do not key this on `_inlineAgentsDefaultedForExperiment`.
// That legacy flag was stamped unconditionally on every successful
// load() in prior builds, regardless of whether the experiment was
// toggled on. Every prior-RC user therefore already has it set to
// true on disk, including the opt-out cohort this widened
// migration was specifically written to reach. Gating on the
// legacy flag would silently skip exactly those users. The
// dedicated `_inlineAgentsDefaultedForAllUsers` flag exists so
// the new default-on migration can distinguish "already migrated
// under the new rules" from "happened to launch a prior build".
//
// Case B preservation: a user who turned the experiment on and then
// deliberately unchecked 'inline-agents' from the sidebar options
// menu has the same on-disk shape as a never-touched user. The
// discriminator below reads the deprecated `experimentalAgentDashboard`
// value as a one-shot signal. Both branches of the migration stamp
// `_inlineAgentsDefaultedForAllUsers`, so subsequent launches don't
// depend on the deprecated value continuing to round-trip.
const rawCardProps = parsed.ui?.worktreeCardProperties
const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true
const hadExperimentOn = readDeprecatedExperimentFlag(parsed)
const deliberateUncheck =
hadExperimentOn &&
Array.isArray(rawCardProps) &&
!rawCardProps.includes('inline-agents')
const needsInlineAgentsMigration =
!inlineAgentsMigrated &&
!deliberateUncheck &&
Array.isArray(rawCardProps) &&
!rawCardProps.includes('inline-agents')
const migratedCardProps = (() => {
if (!Array.isArray(rawCardProps)) {
return undefined
}
const candidate = needsInlineAgentsMigration
? [...rawCardProps, 'inline-agents' as const]
: rawCardProps
// Why: only Agent activity remains configurable; older hidden
// card fields must be restored because users can no longer
// toggle them back on from the sidebar menu.
const normalized = normalizeWorktreeCardProperties(candidate)
const changed =
normalized.length !== rawCardProps.length ||
normalized.some((property, index) => property !== rawCardProps[index])
return changed ? normalized : undefined
})()
if (migratedCardProps !== undefined || !inlineAgentsMigrated) {
this.loadNeedsSave = true
}
return {
...defaults.ui,
...parsed.ui,
sortBy: migrate ? ('smart' as const) : sort,
workspaceStatuses,
_workspaceStatusesDefaultOrderMigrated: true,
_workspaceStatusesDefaultWorkflowMigrated: true,
_workspaceStatusesDefaultVisualsMigrated: true,
_sortBySmartMigrated: true,
...(migratedCardProps !== undefined
? { worktreeCardProperties: migratedCardProps }
: {}),
// Why: keep stamping the legacy flag for forward-compat with
// a rollback to a pre-default-on build that still reads it.
// The new flag is the one that actually gates the migration.
_inlineAgentsDefaultedForExperiment: true,
_inlineAgentsDefaultedForAllUsers: true
}
})(),
// Why: the workspace session is the most volatile persisted surface
// (schema evolves per release, daemon session IDs embedded in it).
// Zod-validate at the read boundary so a field-type flip from an
// older build — or a truncated write from a crash — gets rejected
// cleanly instead of poisoning Zustand state and crashing the
// renderer on mount. On validation failure, fall back to defaults
// and log; a corrupt session file shouldn't trap the user out.
workspaceSession: (() => {
if (parsed.workspaceSession === undefined) {
return defaults.workspaceSession
}
const result = parseWorkspaceSession(parsed.workspaceSession)
if (!result.ok) {
console.error(
'[persistence] Corrupt workspace session, using defaults:',
result.error
)
return defaults.workspaceSession
}
return { ...defaults.workspaceSession, ...result.value }
})(),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
.map(normalizeSshRemotePtyLease)
.filter((lease): lease is SshRemotePtyLease => lease !== null),
migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries(
parsed.migrationUnsupportedPtyEntries
),
legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries(
parsed.legacyPaneKeyAliasEntries
),
automations: Array.isArray(parsed.automations) ? parsed.automations : [],
automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [],
onboarding: (() => {
// Why: if we successfully parsed an existing orca-data.json that
// lacks an onboarding block, this is an upgrade-cohort user —
// backfill as completed (not dismissed) so they don't get dropped
// into the wizard regardless of whether they currently have repos,
// SSH targets, or just non-default settings. Analytics still
// distinguish this from users who explicitly bailed mid-funnel.
if (!parsed.onboarding) {
return {
...defaults.onboarding,
closedAt: Date.now(),
outcome: 'completed' as const,
lastCompletedStep: ONBOARDING_FINAL_STEP
}
}
// Why: validate every persisted onboarding key explicitly via the
// shared sanitizer instead of spreading raw values. A type-flipped
// field on disk (string where number expected, unknown checklist
// key) is dropped or coerced to the default rather than poisoning
// in-memory state.
const sanitized = sanitizeOnboardingUpdate(parsed.onboarding)
return {
...defaults.onboarding,
...sanitized,
checklist: {
...defaults.onboarding.checklist,
...sanitized.checklist
}
}
})()
}
}
} catch (err) {
console.error('[persistence] Failed to load primary state, trying backups:', err)
}
// Corrupt-file catch path and "no file on disk" path converge here. The
// telemetry migration below runs on whichever branch produced `result`,
// because a user whose `orca-data.json` got corrupted is not a fresh
// install of the telemetry release — they still count as existing and
// must see the opt-in banner, not the default-on toast.
if (result === null && allowBackupRecovery) {
let hasBackup = false
for (let i = 0; i < BACKUP_COUNT; i++) {
if (existsSync(backupPath(dataFile, i))) {
hasBackup = true
break
}
}
if (fileExistedOnLoad || hasBackup) {
if (this.restoreFromBackup(dataFile)) {
return this.load(false)
}
console.error('[persistence] No usable state file or backup found, using defaults')
}
}
if (result === null) {
result = getDefaultPersistedState(homedir())
}
result = {
...result,
workspaceSession: pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
)
}
return this.migrateTelemetry(result, fileExistedOnLoad)
}
// One-shot telemetry cohort migration. Runs on every `load()` but is a
// no-op once `existedBeforeTelemetryRelease` is set, so subsequent launches
// pay only the property lookup. Populates:
// - `existedBeforeTelemetryRelease` — cohort discriminator (drives
// whether the existing-user opt-in banner is shown in PR 3;
// new users get no first-launch surface).
// - `optedIn` — new users start opted in; existing users are `null` until
// the banner resolves (the consent resolver returns `pending_banner`
// until then, so nothing transmits).
// - `installId` — anonymous UUID v4. Stable across launches; not surfaced in the UI.
private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState {
const existing = state.settings?.telemetry
// Why: the one-shot is complete only when all three invariants hold.
// Keying on `existedBeforeTelemetryRelease` alone would let a partially-
// written telemetry block (crash mid-save, hand-edit, future bug) short-
// circuit migration and leave `installId` undefined or `optedIn` wiped.
if (
typeof existing?.existedBeforeTelemetryRelease === 'boolean' &&
typeof existing.installId === 'string' &&
existing.installId.length > 0 &&
(existing.optedIn === true || existing.optedIn === false || existing.optedIn === null)
) {
return state
}
// Why: cohort is the authoritative discriminator per invariant #8, so
// resolve it once and reuse it below — the `optedIn` fallback must not
// re-infer cohort from `fileExistedOnLoad` or field presence, or a
// partially-written telemetry block could land a new user in the
// existing-user `pending_banner` state.
const resolvedExistedBefore =
typeof existing?.existedBeforeTelemetryRelease === 'boolean'
? existing.existedBeforeTelemetryRelease
: fileExistedOnLoad
return {
...state,
settings: {
...state.settings,
telemetry: {
...existing,
existedBeforeTelemetryRelease: resolvedExistedBefore,
// Why: preserve an explicit opt-in/out if the user has ever resolved
// it. Only fall back to the cohort default (new users: on; existing
// users: undecided until the first-launch banner resolves) when
// optedIn is truly unset (undefined), never when it is `false`.
optedIn:
existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null
? existing.optedIn
: resolvedExistedBefore
? null
: true,
installId:
typeof existing?.installId === 'string' && existing.installId.length > 0
? existing.installId
: randomUUID()
}
}
}
}
private scheduleSave(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
}
this.writeTimer = setTimeout(() => {
this.writeTimer = null
// Why (issue #1158): serialize async writes so backup rotation never has
// two callers racing over the same dataFile/tmp/.bak paths.
const prev = this.pendingWrite ?? Promise.resolve()
const next = prev
.then(() => this.writeToDiskAsync())
.catch((err) => {
console.error('[persistence] Failed to write state:', err)
})
.finally(() => {
if (this.pendingWrite === next) {
this.pendingWrite = null
}
})
this.pendingWrite = next
}, 300)
}
/** Wait for any in-flight async disk write to complete. Used in tests. */
async waitForPendingWrite(): Promise<void> {
if (this.pendingWrite) {
await this.pendingWrite
}
}
// Why: async writes avoid blocking the main Electron thread on every
// debounced save (every 300ms during active use).
private async writeToDiskAsync(): Promise<void> {
const gen = this.writeGeneration
const dataFile = getDataFile()
const dir = dirname(dataFile)
await mkdir(dir, { recursive: true }).catch(() => {})
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
// Why: opencodeSessionCookie must be encrypted on disk. Clone state so
// the in-memory this.state stays plaintext for the rest of the app.
const stateToSave = {
...this.state,
settings: {
...this.state.settings,
opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie)
},
ui: {
...this.state.ui,
browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink)
}
}
// Why: wrap write+rename in try/finally-on-error so any failure (ENOSPC,
// ENFILE, EIO, permission) removes the tmp file rather than leaving a
// multi-megabyte orphan behind. Successful rename consumes the tmp file.
let renamed = false
try {
await writeFile(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8')
// Why: if flush() ran while this async write was in-flight, it bumped
// writeGeneration and already wrote the latest state synchronously.
// Renaming this stale tmp file would overwrite the fresh data.
if (this.writeGeneration !== gen) {
return
}
await rename(tmpFile, dataFile)
renamed = true
} finally {
if (!renamed) {
await rm(tmpFile).catch(() => {})
}
}
// Why (issue #1158): rotate only after the atomic rename succeeded; then
// re-check the generation so a concurrent flush owns any backup rotation.
if (this.writeGeneration !== gen) {
return
}
const now = Date.now()
if (this.shouldRotateBackups(now, dataFile)) {
await this.rotateBackupsAsync(dataFile)
}
}
// Why: synchronous variant kept only for flush() at shutdown, where the
// process may exit before an async write completes.
private writeToDiskSync(): void {
const dataFile = getDataFile()
const dir = dirname(dataFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
// Why: opencodeSessionCookie must be encrypted on disk. Clone state so
// the in-memory this.state stays plaintext for the rest of the app.
const stateToSave = {
...this.state,
settings: {
...this.state.settings,
opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie)
},
ui: {
...this.state.ui,
browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink)
}
}
// Why: mirror the async path — on any failure between writeFileSync and
// renameSync, remove the tmp file so crashes during shutdown don't leak
// orphans into userData.
let renamed = false
try {
writeFileSync(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8')
renameSync(tmpFile, dataFile)
renamed = true
} finally {
if (!renamed) {
try {
unlinkSync(tmpFile)
} catch {
// Best-effort cleanup; the write already failed, swallow secondary error.
}
}
}
const now = Date.now()
if (this.shouldRotateBackups(now, dataFile)) {
this.rotateBackupsSync(dataFile)
}
}
private flushOrThrow(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
// Why: bump writeGeneration so any in-flight async writeToDiskAsync skips
// its rename, preventing a stale snapshot from overwriting this sync write.
this.writeGeneration++
this.pendingWrite = null
this.writeToDiskSync()
}
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
/**
* O(1) read of the persisted repo count. Use this when you only need the
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and
* may run a synchronous git subprocess via `getGitUsername()`, which is
* wasteful when the caller only reads `.length`.
*/
getRepoCount(): number {
return this.state.repos.length
}
getRepo(id: string): Repo | undefined {
const repo = this.state.repos.find((r) => r.id === id)
return repo ? this.hydrateRepo(repo) : undefined
}
addRepo(repo: Repo): void {
this.state.repos.push(repo)
this.scheduleSave()
}
// Why: returns false on a stale permutation (concurrent add/remove races
// the renderer's drag) so the caller can tell the renderer to resync rather
// than persist an order that drops or duplicates ids.
reorderRepos(orderedIds: string[]): boolean {
const current = this.state.repos
if (orderedIds.length !== current.length) {
return false
}
const seen = new Set<string>()
for (const id of orderedIds) {
if (typeof id !== 'string' || seen.has(id)) {
return false
}
seen.add(id)
}
const byId = new Map<string, Repo>()
for (const r of current) {
byId.set(r.id, r)
}
const next: Repo[] = []
for (const id of orderedIds) {
const repo = byId.get(id)
if (!repo) {
return false
}
next.push(repo)
}
this.state.repos = next
this.scheduleSave()
return true
}
removeRepo(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
// Why: presets are repo-scoped, so removing the repo means the presets
// can never be referenced again — drop them with the parent.
delete this.state.sparsePresetsByRepo[id]
// Clean up worktree meta for this repo
const prefix = `${id}::`
for (const key of Object.keys(this.state.worktreeMeta)) {
if (key.startsWith(prefix)) {
delete this.state.worktreeMeta[key]
}
}
for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) {
if (childId.startsWith(prefix) || lineage.parentWorktreeId.startsWith(prefix)) {
delete this.state.worktreeLineageById[childId]
}
}
this.scheduleSave()
}
updateRepo(
id: string,
updates: Partial<
Pick<
Repo,
| 'displayName'
| 'badgeColor'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'
| 'issueSourcePreference'
>
>
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return null
}
// Why: `issueSourcePreference === undefined` in the patch means "reset to
// auto" (and the persisted record should drop the key, not preserve a
// stale explicit value via Object.assign's skip-on-undefined behavior).
// Without this delete branch, toggling explicit → auto would silently
// leave the old preference in place on disk.
if ('issueSourcePreference' in updates && updates.issueSourcePreference === undefined) {
delete repo.issueSourcePreference
const { issueSourcePreference: _drop, ...rest } = updates
Object.assign(repo, rest)
} else {
Object.assign(repo, updates)
}
this.scheduleSave()
return this.hydrateRepo(repo)
}
private hydrateRepo(repo: Repo): Repo {
const gitUsername = isFolderRepo(repo)
? ''
: (this.gitUsernameCache.get(repo.path) ??
(() => {
const username = getGitUsername(repo.path)
this.gitUsernameCache.set(repo.path, username)
return username
})())
return {
...repo,
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {
...getDefaultRepoHookSettings(),
...repo.hookSettings,
scripts: {
...getDefaultRepoHookSettings().scripts,
...repo.hookSettings?.scripts
}
}
}
}
// ── Sparse Presets ─────────────────────────────────────────────────
getSparsePresets(repoId: string): SparsePreset[] {
return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
left.name.localeCompare(right.name)
)
}
saveSparsePreset(preset: SparsePreset): SparsePreset {
const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? []
const index = existing.findIndex((entry) => entry.id === preset.id)
this.state.sparsePresetsByRepo[preset.repoId] =
index === -1
? [...existing, preset]
: existing.map((entry, i) => (i === index ? preset : entry))
this.scheduleSave()
return preset
}
removeSparsePreset(repoId: string, presetId: string): void {
const existing = this.state.sparsePresetsByRepo[repoId] ?? []
this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId)
this.scheduleSave()
}
// ── Automations ───────────────────────────────────────────────────
listAutomations(): Automation[] {
return [...(this.state.automations ?? [])].sort((left, right) =>
left.name.localeCompare(right.name)
)
}
listAutomationRuns(automationId?: string): AutomationRun[] {
const runs = this.state.automationRuns ?? []
return [
...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)
].sort((left, right) => right.createdAt - left.createdAt)
}
createAutomation(input: AutomationCreateInput): Automation {
const repo = this.state.repos.find((entry) => entry.id === input.projectId)
const now = Date.now()
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const automation: Automation = {
id: randomUUID(),
name: input.name.trim() || 'Untitled automation',
prompt: input.prompt,
agentId: input.agentId,
projectId: input.projectId,
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service',
workspaceMode: input.workspaceMode,
workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null,
baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null,
timezone: input.timezone,
rrule: input.rrule,
dtstart: input.dtstart,
enabled: input.enabled ?? true,
nextRunAt: nextAutomationOccurrenceAfter(input.rrule, input.dtstart, now),
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: input.missedRunGraceMinutes ?? 720,
createdAt: now,
updatedAt: now
}
this.state.automations = [...(this.state.automations ?? []), automation]
this.flush()
return automation
}
updateAutomation(id: string, updates: AutomationUpdateInput): Automation {
const index = (this.state.automations ?? []).findIndex((entry) => entry.id === id)
if (index === -1) {
throw new Error('Automation not found.')
}
const current = this.state.automations[index]
const repoId = updates.projectId ?? current.projectId
const repo = this.state.repos.find((entry) => entry.id === repoId)
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const rrule = updates.rrule ?? current.rrule
const dtstart = updates.dtstart ?? current.dtstart
const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined
const workspaceMode = updates.workspaceMode ?? current.workspaceMode
const updated: Automation = {
...current,
...updates,
name:
updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name,
projectId: repoId,
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service',
workspaceMode,
workspaceId:
workspaceMode === 'existing'
? Object.hasOwn(updates, 'workspaceId')
? (updates.workspaceId ?? null)
: current.workspaceId
: null,
baseBranch:
workspaceMode === 'new_per_run'
? Object.hasOwn(updates, 'baseBranch')
? (updates.baseBranch ?? null)
: (current.baseBranch ?? null)
: null,
rrule,
dtstart,
nextRunAt: scheduleChanged
? nextAutomationOccurrenceAfter(rrule, dtstart, Date.now())
: current.nextRunAt,
updatedAt: Date.now()
}
this.state.automations[index] = updated
this.flush()
return updated
}
deleteAutomation(id: string): void {
this.state.automations = (this.state.automations ?? []).filter((entry) => entry.id !== id)
this.state.automationRuns = (this.state.automationRuns ?? []).filter(
(entry) => entry.automationId !== id
)
this.flush()
}
createAutomationRun(
automation: Automation,
scheduledFor: number,
trigger: AutomationRunTrigger = 'scheduled'
): AutomationRun {
const existing = (this.state.automationRuns ?? []).find(
(run) => run.automationId === automation.id && run.scheduledFor === scheduledFor
)
if (existing) {
return existing
}
const now = Date.now()
const runNumber =
(this.state.automationRuns ?? []).filter((run) => run.automationId === automation.id).length +
1
const run: AutomationRun = {
id: randomUUID(),
automationId: automation.id,
title: `${automation.name} run ${runNumber}`,
scheduledFor,
status: 'pending',
trigger,
workspaceId: automation.workspaceId,
workspaceDisplayName: this.getAutomationRunWorkspaceDisplayName(automation.workspaceId),
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
outputSnapshot: null,
usage: null,
error: null,
startedAt: null,
dispatchedAt: null,
createdAt: now
}
this.state.automationRuns = [...(this.state.automationRuns ?? []), run]
this.flush()
return run
}
updateAutomationRun(result: AutomationDispatchResult): AutomationRun {
const index = (this.state.automationRuns ?? []).findIndex((entry) => entry.id === result.runId)
if (index === -1) {
throw new Error('Automation run not found.')
}
const now = Date.now()
const current = this.state.automationRuns[index]
const workspaceId = result.workspaceId ?? current.workspaceId
const workspaceDisplayName = Object.hasOwn(result, 'workspaceDisplayName')
? normalizeAutomationRunWorkspaceDisplayName(result.workspaceDisplayName ?? null)
: null
const updated: AutomationRun = {
...current,
status: result.status,
workspaceId,
workspaceDisplayName:
workspaceDisplayName ??
normalizeAutomationRunWorkspaceDisplayName(current.workspaceDisplayName ?? null) ??
this.getAutomationRunWorkspaceDisplayName(workspaceId),
terminalSessionId: result.terminalSessionId ?? current.terminalSessionId,
outputSnapshot: Object.hasOwn(result, 'outputSnapshot')
? normalizeAutomationRunOutputSnapshot(result.outputSnapshot)
: normalizeAutomationRunOutputSnapshot(current.outputSnapshot),
usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null),
error: result.error ?? null,
startedAt: current.startedAt ?? now,
dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt
}
this.state.automationRuns[index] = updated
const automation = this.state.automations.find((entry) => entry.id === updated.automationId)
if (automation) {
automation.lastRunAt = now
automation.updatedAt = now
}
this.flush()
return updated
}
snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number {
const normalizedDisplayName = normalizeAutomationRunWorkspaceDisplayName(displayName)
if (!normalizedDisplayName) {
return 0
}
let updatedCount = 0
this.state.automationRuns = (this.state.automationRuns ?? []).map((run) => {
if (run.workspaceId !== workspaceId || run.workspaceDisplayName === normalizedDisplayName) {
return run
}
updatedCount += 1
return { ...run, workspaceDisplayName: normalizedDisplayName }
})
if (updatedCount > 0) {
this.flush()
}
return updatedCount
}
private getAutomationRunWorkspaceDisplayName(
workspaceId: string | null | undefined
): string | null {
if (!workspaceId) {
return null
}
return normalizeAutomationRunWorkspaceDisplayName(
this.state.worktreeMeta[workspaceId]?.displayName ??
getWorktreePathBasenameFromId(workspaceId)
)
}
advanceAutomationNextRun(id: string, now = Date.now()): Automation {
const index = (this.state.automations ?? []).findIndex((entry) => entry.id === id)
if (index === -1) {
throw new Error('Automation not found.')
}
const current = this.state.automations[index]
const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now)
const updated = { ...current, nextRunAt, updatedAt: Date.now() }
this.state.automations[index] = updated
this.flush()
return updated
}
getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null {
return latestAutomationOccurrenceAtOrBefore(automation.rrule, automation.dtstart, now)
}
// ── Worktree Meta ──────────────────────────────────────────────────
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
return this.state.worktreeMeta[worktreeId]
}
getAllWorktreeMeta(): Record<string, WorktreeMeta> {
return this.state.worktreeMeta
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta()
const updated = { ...existing, ...meta }
if (!updated.instanceId) {
updated.instanceId = randomUUID()
}
this.state.worktreeMeta[worktreeId] = updated
this.scheduleSave()
return updated
}
removeWorktreeMeta(worktreeId: string): void {
delete this.state.worktreeMeta[worktreeId]
delete this.state.worktreeLineageById[worktreeId]
this.scheduleSave()
}
getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined {
return this.state.worktreeLineageById[worktreeId]
}
getAllWorktreeLineage(): Record<string, WorktreeLineage> {
return this.state.worktreeLineageById
}
setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage {
this.state.worktreeLineageById[worktreeId] = lineage
this.scheduleSave()
return lineage
}
removeWorktreeLineage(worktreeId: string): void {
delete this.state.worktreeLineageById[worktreeId]
this.scheduleSave()
}
// ── Settings ───────────────────────────────────────────────────────
getSettings(): GlobalSettings {
return this.state.settings
}
updateSettings(updates: Partial<GlobalSettings>): GlobalSettings {
const sanitizedUpdates = { ...updates }
if ('terminalQuickCommands' in updates) {
sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands(
updates.terminalQuickCommands
)
}
if ('visibleTaskProviders' in updates) {
sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders(
updates.visibleTaskProviders
)
}
if ('openInApplications' in updates) {
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications)
}
// Why: `telemetry` is deep-merged for the same reason `notifications` is —
// partial updates from the Privacy pane / consent flow (e.g., flipping
// only `optedIn`) must not clobber sibling fields like `installId` or
// `existedBeforeTelemetryRelease`. The field is optional, so we only
// synthesize a `telemetry` key on the result when at least one side has
// one.
const mergedTelemetry =
sanitizedUpdates.telemetry !== undefined
? { ...this.state.settings.telemetry, ...sanitizedUpdates.telemetry }
: this.state.settings.telemetry
this.state.settings = {
...this.state.settings,
...sanitizedUpdates,
notifications: {
...this.state.settings.notifications,
...sanitizedUpdates.notifications
},
...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {})
}
this.scheduleSave()
return this.state.settings
}
// ── UI State ───────────────────────────────────────────────────────
getUI(): PersistedState['ui'] {
return {
...getDefaultUIState(),
...this.state.ui,
groupBy: normalizeGroupBy(this.state.ui?.groupBy),
sortBy: normalizeSortBy(this.state.ui?.sortBy),
worktreeCardProperties: normalizeWorktreeCardProperties(
this.state.ui?.worktreeCardProperties
),
workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses),
workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity),
workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
this.state.ui?.workspaceBoardColumnWidth
)
}
}
updateUI(updates: Partial<PersistedState['ui']>): void {
this.state.ui = {
...this.state.ui,
...updates,
groupBy: updates.groupBy
? normalizeGroupBy(updates.groupBy)
: normalizeGroupBy(this.state.ui?.groupBy),
sortBy: updates.sortBy
? normalizeSortBy(updates.sortBy)
: normalizeSortBy(this.state.ui?.sortBy),
worktreeCardProperties:
updates.worktreeCardProperties !== undefined
? normalizeWorktreeCardProperties(updates.worktreeCardProperties)
: normalizeWorktreeCardProperties(this.state.ui?.worktreeCardProperties),
workspaceStatuses:
updates.workspaceStatuses !== undefined
? normalizeWorkspaceStatuses(updates.workspaceStatuses)
: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses),
workspaceBoardOpacity: clampWorkspaceBoardOpacity(
updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity
),
workspaceBoardCompact: normalizeWorkspaceBoardCompact(
updates.workspaceBoardCompact ?? this.state.ui?.workspaceBoardCompact
),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth
)
}
this.scheduleSave()
}
// ── Onboarding ────────────────────────────────────────────────────
getOnboarding(): PersistedState['onboarding'] {
const defaults = getDefaultOnboardingState()
return {
...defaults,
...this.state.onboarding,
checklist: {
...defaults.checklist,
...this.state.onboarding?.checklist
}
}
}
updateOnboarding(
updates: Partial<Omit<PersistedState['onboarding'], 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
}
): PersistedState['onboarding'] {
const current = this.getOnboarding()
this.state.onboarding = {
...current,
...updates,
checklist: {
...current.checklist,
...updates.checklist
}
}
this.scheduleSave()
return this.getOnboarding()
}
// ── GitHub Cache ──────────────────────────────────────────────────
getGitHubCache(): PersistedState['githubCache'] {
return this.state.githubCache
}
setGitHubCache(cache: PersistedState['githubCache']): void {
this.state.githubCache = cache
this.scheduleSave()
}
// ── Workspace Session ─────────────────────────────────────────────
getWorkspaceSession(): PersistedState['workspaceSession'] {
return this.state.workspaceSession ?? getDefaultWorkspaceSession()
}
setWorkspaceSession(session: PersistedState['workspaceSession']): void {
session = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
)
// Why: closes the second half of the SIGKILL race (Issue #217). The
// renderer's debounced session writer captures its state BEFORE pty:spawn
// returns, so the snapshot it later flushes via session:set has no
// tab.ptyId / ptyIdsByLeafId for the just-spawned PTY. If that stale
// snapshot lands AFTER persistPtyBinding's sync flush, it would overwrite
// the durable binding and re-open the orphan window. Merge in any
// existing bindings whenever the incoming snapshot's binding is empty.
const prior = this.state.workspaceSession
const normalized = normalizeWorkspaceSessionPaneIdentities(
session,
prior?.terminalLayoutsByTabId
)
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys(
this.state.ui?.acknowledgedAgentsByPaneKey,
normalized.leafIdByInputLeafIdByTabId
)
if (remappedAcknowledgements.changed) {
this.state.ui = {
...this.state.ui,
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
}
}
for (const entry of normalized.legacyPaneKeyAliasEntries) {
agentHookServer.registerPaneKeyAlias(
entry.legacyPaneKey,
entry.stablePaneKey,
entry.ptyId,
entry.updatedAt,
{ overwriteExisting: false }
)
}
session = normalized.session
const remappedLeases = remapSshRemotePtyLeaseLeafIds(
this.state.sshRemotePtyLeases ?? [],
normalized.leafIdByInputLeafIdByTabId,
normalized.leafIdByPtyIdByTabId
)
if (remappedLeases.changed) {
this.state.sshRemotePtyLeases = remappedLeases.leases
}
if (session && prior) {
const priorTabs = prior.tabsByWorktree ?? {}
const nextTabs = session.tabsByWorktree ?? {}
const worktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) {
for (const tab of tabs) {
worktreeIdByTabId.set(tab.id, worktreeId)
}
}
for (const [worktreeId, tabs] of Object.entries(nextTabs)) {
const priorList = priorTabs[worktreeId]
if (!priorList) {
continue
}
for (const tab of tabs) {
if (tab.ptyId) {
continue
}
const priorTab = priorList.find((t) => t.id === tab.id)
if (
priorTab?.ptyId &&
this.isRestorablePtyBinding({
ptyId: priorTab.ptyId,
worktreeId,
targetId: this.getConnectionIdForWorktree(worktreeId),
tabId: tab.id
})
) {
tab.ptyId = priorTab.ptyId
}
}
}
const priorLayouts = prior.terminalLayoutsByTabId ?? {}
const nextLayouts = session.terminalLayoutsByTabId ?? {}
for (const [tabId, layout] of Object.entries(nextLayouts)) {
const priorLayout = priorLayouts[tabId]
if (!priorLayout?.ptyIdsByLeafId) {
continue
}
const incoming = layout.ptyIdsByLeafId ?? {}
const incomingHasAnyBinding = Object.keys(incoming).length > 0
const liveLeafIds = this.getTerminalLayoutLeafIds(layout.root)
const worktreeId = worktreeIdByTabId.get(tabId)
const targetId = worktreeId ? this.getConnectionIdForWorktree(worktreeId) : null
const restorableBindings = Object.fromEntries(
Object.entries(priorLayout.ptyIdsByLeafId).filter(
([leafId, ptyId]) =>
liveLeafIds.has(leafId) &&
incoming[leafId] === undefined &&
// Why: an empty layout map can be a stale pre-spawn snapshot; a
// partial map is intentional unless a durable SSH lease proves it.
(incomingHasAnyBinding
? this.hasRestorableSshRemotePtyLease({
ptyId,
targetId,
worktreeId,
tabId,
leafId
})
: this.isRestorablePtyBinding({ ptyId, targetId, worktreeId, tabId, leafId }))
)
)
if (Object.keys(restorableBindings).length > 0) {
layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming }
// Why: the same stale session write that drops ptyIdsByLeafId can
// also be from an older renderer that lacks UUID-keyed metadata.
const buffersByLeafId = preserveMissingLeafRecordEntries(
priorLayout.buffersByLeafId,
layout.buffersByLeafId,
liveLeafIds
)
const titlesByLeafId = preserveMissingLeafRecordEntries(
priorLayout.titlesByLeafId,
layout.titlesByLeafId,
liveLeafIds
)
if (buffersByLeafId) {
layout.buffersByLeafId = buffersByLeafId
}
if (titlesByLeafId) {
layout.titlesByLeafId = titlesByLeafId
}
}
}
}
this.state.workspaceSession = session
this.scheduleSave()
}
private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set<string> {
const leafIds = new Set<string>()
const visit = (node: TerminalPaneLayoutNode | null): void => {
if (!node) {
return
}
if (node.type === 'leaf') {
if (isTerminalLeafId(node.leafId)) {
leafIds.add(node.leafId)
}
return
}
visit(node.first)
visit(node.second)
}
visit(root)
return leafIds
}
private isRestorablePtyBinding(binding: {
ptyId: string
targetId?: string | null
worktreeId?: string
tabId?: string
leafId?: string
}): boolean {
const leases = this.state.sshRemotePtyLeases?.filter((entry) =>
this.sshRemotePtyLeaseMatchesBinding(entry, binding)
)
return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired')
}
private sshRemotePtyLeaseMatchesBinding(
lease: SshRemotePtyLease,
binding: {
ptyId: string
targetId?: string | null
worktreeId?: string
tabId?: string
leafId?: string
}
): boolean {
if (lease.ptyId !== binding.ptyId) {
return false
}
// Why: remote PTY ids are scoped to a relay target. Workspace PTY bindings
// only store the id, so derive target/context when possible and require
// stored lease context to match instead of treating missing fields as
// wildcards that can tombstone unrelated panes.
return (
(binding.targetId === undefined ||
binding.targetId === null ||
lease.targetId === binding.targetId) &&
(binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) &&
(binding.tabId === undefined || lease.tabId === binding.tabId) &&
(binding.leafId === undefined || lease.leafId === binding.leafId)
)
}
private hasRestorableSshRemotePtyLease(binding: {
ptyId: string
targetId?: string | null
worktreeId?: string
tabId?: string
leafId?: string
}): boolean {
return (
this.state.sshRemotePtyLeases?.some(
(lease) =>
this.sshRemotePtyLeaseMatchesBinding(lease, binding) &&
lease.state !== 'terminated' &&
lease.state !== 'expired'
) ?? false
)
}
private sshRemotePtyLeaseMayReferenceBinding(
lease: SshRemotePtyLease,
binding: {
ptyId: string
targetId: string
worktreeId?: string
tabId?: string
leafId?: string
}
): boolean {
if (lease.targetId !== binding.targetId || lease.ptyId !== binding.ptyId) {
return false
}
// Why: target removal is destructive. Legacy/contextless leases should
// scrub matching workspace bindings before the lease record is deleted,
// otherwise removing the tombstone can let stale PTY ids revive later.
return (
(binding.worktreeId === undefined ||
lease.worktreeId === undefined ||
lease.worktreeId === binding.worktreeId) &&
(binding.tabId === undefined || lease.tabId === undefined || lease.tabId === binding.tabId) &&
(binding.leafId === undefined ||
lease.leafId === undefined ||
lease.leafId === binding.leafId)
)
}
private getConnectionIdForWorktree(worktreeId: string): string | null {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return this.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null
}
// Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217). The
// renderer's debounced session writer (~450 ms total) is normally the only
// path that writes tab.ptyId / ptyIdsByLeafId; a force-quit inside that
// window orphans the daemon's history dir. Patching + sync flushing here
// before pty:spawn returns guarantees the renderer cannot observe a
// spawn-success without the binding already being durable on disk.
persistPtyBinding(args: {
worktreeId: string
tabId: string
leafId: string
ptyId: string
}): void {
const session = this.state.workspaceSession
if (!session) {
return
}
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
const tabs = session.tabsByWorktree?.[args.worktreeId]
const tab = tabs?.find((t) => t.id === args.tabId)
if (tab) {
tab.ptyId = args.ptyId
} else {
// Why: pty:spawn can beat the debounced session writer for a newly
// created tab. Persist a minimal tab so hydration does not prune the
// crash-safe layout binding below as an orphaned tab id.
const nextTabs = [
...(tabs ?? []),
createMinimalPersistedTerminalTab({
...args,
existingTabCount: tabs?.length ?? 0
})
]
session.tabsByWorktree = {
...session.tabsByWorktree,
[args.worktreeId]: nextTabs
}
session.activeWorktreeId ??= args.worktreeId
session.activeTabId ??= args.tabId
session.activeTabIdByWorktree = {
...session.activeTabIdByWorktree,
[args.worktreeId]: session.activeTabIdByWorktree?.[args.worktreeId] ?? args.tabId
}
}
if (!isTerminalLeafId(args.leafId)) {
// Why: legacy renderer-local pane ids may arrive from older callers; keep
// them out of durable leaf-keyed layout state after the UUID migration.
try {
this.flushOrThrow()
} catch (err) {
this.state.workspaceSession = sessionBeforeBinding
throw err
}
return
}
const layout = session.terminalLayoutsByTabId?.[args.tabId]
if (layout) {
if (!layout.root) {
// Why: createTab can persist an empty layout before TerminalPane mounts.
// The sync spawn binding must still leave a durable UUID root behind.
layout.root = { type: 'leaf', leafId: args.leafId }
layout.activeLeafId = args.leafId
layout.expandedLeafId = null
} else if (!layoutContainsLeafId(layout.root, args.leafId)) {
// Why: splitPane publishes the new pane and starts pty:spawn before the
// debounced full layout snapshot reaches main. Add a minimal leaf so a
// crash in that window cannot make the new pane's binding unreachable.
layout.root = {
type: 'split',
direction: 'vertical',
first: cloneLayoutNode(layout.root),
second: { type: 'leaf', leafId: args.leafId }
}
layout.activeLeafId = args.leafId
if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) {
layout.expandedLeafId = null
}
}
layout.ptyIdsByLeafId = {
...layout.ptyIdsByLeafId,
[args.leafId]: args.ptyId
}
} else {
// Why: first-spawn-ever for a new tab — the renderer's debounced writer
// creates the layout entry on PaneManager init, but the binding has to
// be on disk before pty:spawn returns or a SIGKILL inside the same
// window would lose ptyIdsByLeafId for split-pane cold restore. The
// renderer will overwrite this minimal layout once persistLayoutSnapshot
// fires.
session.terminalLayoutsByTabId = {
...session.terminalLayoutsByTabId,
[args.tabId]: {
root: { type: 'leaf', leafId: args.leafId },
activeLeafId: args.leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [args.leafId]: args.ptyId }
}
}
}
try {
this.flushOrThrow()
} catch (err) {
this.state.workspaceSession = sessionBeforeBinding
throw err
}
}
// ── SSH Targets ────────────────────────────────────────────────────
getSshTargets(): SshTarget[] {
return (this.state.sshTargets ?? []).map(normalizeSshTarget)
}
getSshTarget(id: string): SshTarget | undefined {
const target = this.state.sshTargets?.find((t) => t.id === id)
return target ? normalizeSshTarget(target) : undefined
}
addSshTarget(target: SshTarget): void {
this.state.sshTargets ??= []
this.state.sshTargets.push(normalizeSshTarget(target))
this.scheduleSave()
}
updateSshTarget(id: string, updates: Partial<Omit<SshTarget, 'id'>>): SshTarget | null {
const target = this.state.sshTargets?.find((t) => t.id === id)
if (!target) {
return null
}
Object.assign(target, updates, normalizeSshTarget({ ...target, ...updates }))
this.scheduleSave()
return { ...target }
}
removeSshTarget(id: string): void {
if (!this.state.sshTargets) {
return
}
this.state.sshTargets = this.state.sshTargets.filter((t) => t.id !== id)
this.scheduleSave()
}
// ── SSH Remote PTY Leases ──────────────────────────────────────────
getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] {
const leases = this.state.sshRemotePtyLeases ?? []
return leases.filter((lease) => targetId === undefined || lease.targetId === targetId)
}
upsertSshRemotePtyLease(
lease: Omit<SshRemotePtyLease, 'createdAt' | 'updatedAt'> &
Partial<Pick<SshRemotePtyLease, 'createdAt' | 'updatedAt'>>
): void {
this.state.sshRemotePtyLeases ??= []
const normalizedLease = { ...lease }
if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) {
delete normalizedLease.leafId
}
const now = Date.now()
const existingIndex = this.state.sshRemotePtyLeases.findIndex(
(entry) =>
entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId
)
const existing = existingIndex >= 0 ? this.state.sshRemotePtyLeases[existingIndex] : undefined
const next: SshRemotePtyLease = {
...existing,
...normalizedLease,
createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now,
updatedAt: normalizedLease.updatedAt ?? now
}
if (existingIndex >= 0) {
this.state.sshRemotePtyLeases[existingIndex] = next
} else {
this.state.sshRemotePtyLeases.push(next)
}
this.flush()
}
markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void {
const now = Date.now()
let changed = false
this.state.sshRemotePtyLeases ??= []
for (const lease of this.state.sshRemotePtyLeases) {
if (lease.targetId !== targetId || lease.state === state) {
continue
}
if (state === 'detached' && lease.state !== 'attached') {
continue
}
lease.state = state
lease.updatedAt = now
if (state === 'attached') {
lease.lastAttachedAt = now
} else if (state === 'detached') {
lease.lastDetachedAt = now
}
changed = true
}
if (changed) {
this.flush()
}
}
markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void {
const lease = this.state.sshRemotePtyLeases?.find(
(entry) => entry.targetId === targetId && entry.ptyId === ptyId
)
if (!lease || lease.state === state) {
return
}
const now = Date.now()
lease.state = state
lease.updatedAt = now
if (state === 'attached') {
lease.lastAttachedAt = now
} else if (state === 'detached') {
lease.lastDetachedAt = now
}
this.flush()
}
removeSshRemotePtyLease(targetId: string, ptyId: string): void {
const leases = (this.state.sshRemotePtyLeases ?? []).filter(
(lease) => lease.targetId === targetId && lease.ptyId === ptyId
)
const before = this.state.sshRemotePtyLeases?.length ?? 0
this.clearSshRemotePtyBindingsForLeases(targetId, leases)
this.state.sshRemotePtyLeases = (this.state.sshRemotePtyLeases ?? []).filter(
(lease) => lease.targetId !== targetId || lease.ptyId !== ptyId
)
if (this.state.sshRemotePtyLeases.length !== before) {
this.flush()
}
}
removeSshRemotePtyLeases(targetId: string): void {
this.state.sshRemotePtyLeases ??= []
this.clearSshRemotePtyBindingsForTarget(targetId)
const before = this.state.sshRemotePtyLeases.length
this.state.sshRemotePtyLeases = this.state.sshRemotePtyLeases.filter(
(lease) => lease.targetId !== targetId
)
if (this.state.sshRemotePtyLeases.length !== before) {
this.flush()
}
}
private clearSshRemotePtyBindingsForTarget(targetId: string): void {
const leases = this.state.sshRemotePtyLeases?.filter((lease) => lease.targetId === targetId)
this.clearSshRemotePtyBindingsForLeases(targetId, leases ?? [])
}
private clearSshRemotePtyBindingsForLeases(targetId: string, leases: SshRemotePtyLease[]): void {
const session = this.state.workspaceSession
if (!leases?.length || !session) {
return
}
let changed = false
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (
tab.ptyId &&
leases.some((lease) =>
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
ptyId: tab.ptyId!,
worktreeId,
targetId,
tabId: tab.id
})
)
) {
tab.ptyId = null
changed = true
}
}
}
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const bindings = layout.ptyIdsByLeafId
if (!bindings) {
continue
}
const worktreeId = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabId)
)?.[0]
const nextBindings = Object.fromEntries(
Object.entries(bindings).filter(
([leafId, ptyId]) =>
!leases.some((lease) =>
this.sshRemotePtyLeaseMayReferenceBinding(lease, {
ptyId,
targetId,
worktreeId,
tabId,
leafId
})
)
)
)
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
layout.ptyIdsByLeafId = nextBindings
changed = true
}
}
if (changed) {
this.scheduleSave()
}
}
// ── Flush (for shutdown) ───────────────────────────────────────────
flush(): void {
try {
this.flushOrThrow()
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}
}
}
function getDefaultWorktreeMeta(): WorktreeMeta {
return {
instanceId: randomUUID(),
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: Date.now(),
lastActivityAt: 0,
workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID
}
}