From 8b04e060fa52cfd3f28145336e9be82255f53b74 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:41:01 -0700 Subject: [PATCH] refactor(persistence): extract modules to half persistence.ts (#14252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(persistence): extract modules to half persistence.ts * refactor(persistence): tighten the extracted operations seam Review follow-ups on the module extraction, all behavior-neutral. The extracted operations read and mutate the Store's state object in place, but every seam typed it as a bare PersistedState, so nothing at the boundary said a caller must pass the live reference — a future caller handing over a clone would have its writes silently dropped. Name that contract: StoreOwnedPersistedState carries it to every operations interface and every mutating free function. normalizePersistedPaneIdentityState and backfillFolderScopeConnectionIds stay on PersistedState; they build a fresh state rather than mutating the Store's. The six *PersistenceOperations wrappers were constructed per delegate call. They are stateless today, so this was inert, but any future instance state would be lost between calls. Memoize them, and mark state and gitUsernameCache readonly so the compiler enforces the single-assignment invariant memoizing them relies on. Also: restore flushSshPtyConsumerRecovery, whose inlining left its rationale duplicated at both call sites; document that migrateWorktreeIdentity's boolean gates the caller's save, since the extracted function kept no docs of its own; and merge a duplicate shared/types import that was failing lint under --deny-warnings. * delete plan doc * refactor(persistence): add error recovery and improve field cleanup - Rollback failed migrations to prevent corrupted state that blocks retry - Gracefully skip malformed entries in normalization instead of aborting - Strip retired fields to prevent orphaned state and sync issues * refactor(persistence): drop the redundant persistence- filename prefix The extracted modules already live in src/main/persistence/, so name them after the domain they own. Point leftover shared/types imports at the real type modules while touching those files. * refactor(persistence): optimize lookups and fix unsanitized updates - Use Maps instead of repeated array searches for O(1) lookups - Apply sanitized updates instead of raw input in ui-state-update - Compare fields directly rather than JSON strings to avoid false dirty states from persisted key ordering differences * refactor(persistence): group modules into lifecycle folders Move the 42 flat persistence modules into six folders named for what the module does, and lift the Store class out of the barrel so persistence.ts becomes an 8-line public surface. Bodies are unchanged: every moved file diffs clean against HEAD once import blocks are excluded. Only import specifiers were rewritten, by resolving each one to an absolute path and mapping it through the move map. Store keeps its existing max-lines suppression; its baseline entry is repathed rather than re-added. Its 119-method public API sets a ~525-line floor, so it cannot meet the 400-line cap without breaking the API for 153 importers. * Sanitize worktree visibility sources and preferences on hydration Ensure invalid or corrupted data from disk (untracked whitespace, relative paths, bogus preference values) is cleaned during load rather than corrupting the in-memory store. --- config/max-lines-baseline.txt | 2 +- .../persistence-worktree-visibility.test.ts | 28 + src/main/persistence.ts | 8024 +---------------- .../feature-interaction-recording.ts | 72 + .../onboarding-normalization.ts | 214 + .../applying-settings/settings-update.ts | 247 + .../source-control-settings.ts | 46 + .../terminal-settings-migrations.ts | 165 + .../applying-settings/ui-interaction-merge.ts | 102 + .../ui-selection-normalization.ts | 95 + .../applying-settings/ui-state-read.ts | 74 + .../applying-settings/ui-state-update.ts | 201 + .../leasing-ssh-ptys/secret-validation.ts | 11 + .../leasing-ssh-ptys/ssh-normalization.ts | 126 + .../ssh-pty-binding-cleanup.ts | 117 + .../ssh-pty-consumer-recovery.ts | 69 + .../ssh-pty-lease-operations.ts | 204 + .../ssh-target-reassignment.ts | 123 + .../leasing-ssh-ptys/ssh-target-state.ts | 172 + .../loading-store/store-owned-state.ts | 8 + src/main/persistence/loading-store/store.ts | 3847 ++++++++ .../loading-store/user-data-path.ts | 108 + .../floating-workspace-normalization.ts | 72 + .../folder-scope-migration.ts | 86 + .../folder-workspace-operations.ts | 246 + .../pane-alias-normalization.ts | 198 + .../pane-identity-migration.ts | 116 + .../session-owner-fields.ts | 107 + .../session-owner-removal.ts | 98 + .../terminal-layout-normalization.ts | 260 + .../workspace-pane-normalization.ts | 221 + .../automation-context-migration.ts | 232 + .../automation-definition-operations.ts | 158 + .../automation-run-operations.ts | 167 + .../automation-schedule-operations.ts | 31 + .../project-group-operations.ts | 123 + .../project-host-compatibility.ts | 83 + .../tracking-repos/project-host-operations.ts | 231 + .../project-host-setup-update.ts | 109 + .../tracking-repos/repo-hydration.ts | 69 + .../tracking-repos/repo-order-operations.ts | 93 + .../tracking-repos/repo-sanitization.ts | 162 + .../tracking-repos/repo-update-operations.ts | 179 + .../tracking-repos/repo-worktree-pruning.ts | 104 + .../worktree-identity-migration.ts | 205 + .../worktree-metadata-normalization.ts | 121 + .../mobile-pairing-userdata-path.test.ts | 41 + 47 files changed, 9550 insertions(+), 8017 deletions(-) create mode 100644 src/main/persistence/applying-settings/feature-interaction-recording.ts create mode 100644 src/main/persistence/applying-settings/onboarding-normalization.ts create mode 100644 src/main/persistence/applying-settings/settings-update.ts create mode 100644 src/main/persistence/applying-settings/source-control-settings.ts create mode 100644 src/main/persistence/applying-settings/terminal-settings-migrations.ts create mode 100644 src/main/persistence/applying-settings/ui-interaction-merge.ts create mode 100644 src/main/persistence/applying-settings/ui-selection-normalization.ts create mode 100644 src/main/persistence/applying-settings/ui-state-read.ts create mode 100644 src/main/persistence/applying-settings/ui-state-update.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/secret-validation.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts create mode 100644 src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts create mode 100644 src/main/persistence/loading-store/store-owned-state.ts create mode 100644 src/main/persistence/loading-store/store.ts create mode 100644 src/main/persistence/loading-store/user-data-path.ts create mode 100644 src/main/persistence/restoring-sessions/floating-workspace-normalization.ts create mode 100644 src/main/persistence/restoring-sessions/folder-scope-migration.ts create mode 100644 src/main/persistence/restoring-sessions/folder-workspace-operations.ts create mode 100644 src/main/persistence/restoring-sessions/pane-alias-normalization.ts create mode 100644 src/main/persistence/restoring-sessions/pane-identity-migration.ts create mode 100644 src/main/persistence/restoring-sessions/session-owner-fields.ts create mode 100644 src/main/persistence/restoring-sessions/session-owner-removal.ts create mode 100644 src/main/persistence/restoring-sessions/terminal-layout-normalization.ts create mode 100644 src/main/persistence/restoring-sessions/workspace-pane-normalization.ts create mode 100644 src/main/persistence/scheduling-automations/automation-context-migration.ts create mode 100644 src/main/persistence/scheduling-automations/automation-definition-operations.ts create mode 100644 src/main/persistence/scheduling-automations/automation-run-operations.ts create mode 100644 src/main/persistence/scheduling-automations/automation-schedule-operations.ts create mode 100644 src/main/persistence/tracking-repos/project-group-operations.ts create mode 100644 src/main/persistence/tracking-repos/project-host-compatibility.ts create mode 100644 src/main/persistence/tracking-repos/project-host-operations.ts create mode 100644 src/main/persistence/tracking-repos/project-host-setup-update.ts create mode 100644 src/main/persistence/tracking-repos/repo-hydration.ts create mode 100644 src/main/persistence/tracking-repos/repo-order-operations.ts create mode 100644 src/main/persistence/tracking-repos/repo-sanitization.ts create mode 100644 src/main/persistence/tracking-repos/repo-update-operations.ts create mode 100644 src/main/persistence/tracking-repos/repo-worktree-pruning.ts create mode 100644 src/main/persistence/tracking-repos/worktree-identity-migration.ts create mode 100644 src/main/persistence/tracking-repos/worktree-metadata-normalization.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index b67403fdea6..d053a6be4e6 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -50,7 +50,7 @@ inline src/main/linear/issues.ts inline src/main/linear/projects.ts inline src/main/memory/collector.ts inline src/main/opencode/hook-service.ts -inline src/main/persistence.ts +inline src/main/persistence/loading-store/store.ts inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts inline src/main/project-groups/nested-repo-discovery.ts diff --git a/src/main/persistence-worktree-visibility.test.ts b/src/main/persistence-worktree-visibility.test.ts index fd308473dcc..55f81617c03 100644 --- a/src/main/persistence-worktree-visibility.test.ts +++ b/src/main/persistence-worktree-visibility.test.ts @@ -3,6 +3,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { GlobalSettings } from '../shared/global-settings-types' +import type { ExternalWorktreeVisibility } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' import { testState, createStore, writeDataFile, makeRepo } from './persistence-test-harness' @@ -234,6 +235,33 @@ describe('Store', () => { expect(reloaded.getRepo('r1')?.externalWorktreeVisibilityLegacy).toBe(false) }) + it('sanitizes raw custom worktree visibility sources on the read path', async () => { + const persisted = getDefaultPersistedState(testState.dir) + persisted.repos = [ + makeRepo({ + id: 'r1', + customWorktreeVisibilitySources: [ + { id: 'team', rootPath: ' /srv/team-worktrees ' }, + { id: 'invalid', rootPath: '../relative' } + ], + worktreeVisibilitySourcePreferences: { + builtIn: { claude: 'show', gsd: 'show' }, + custom: { team: 'show', missing: 'bogus' as unknown as ExternalWorktreeVisibility } + } + }) + ] + writeDataFile(persisted) + + const store = await createStore() + expect(store.getRepo('r1')).toMatchObject({ + customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }], + worktreeVisibilitySourcePreferences: { + builtIn: { claude: 'show', gsd: 'show' }, + custom: { team: 'show' } + } + }) + }) + it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => { const store = await createStore() store.addRepo( diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 253ea79ef1a..bb037900c7c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,8016 +1,8 @@ -/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { app } from 'electron' -import { - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - renameSync, - unlinkSync, - copyFileSync, - statSync, - realpathSync -} from 'node:fs' -import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' -import { - durableWriteTempPath, - removeStaleDurableWriteTempFiles, - renameDurable, - writeFileDurableSync -} from './durable-file-write' -import { join, dirname, isAbsolute, resolve, sep } from 'node:path' -import { homedir } from 'node:os' -import { createHash, randomUUID } from 'node:crypto' -import type { - Automation, - AutomationCreateInput, - AutomationDispatchResult, - AutomationPrecheckResult, - AutomationRunOutputSnapshot, - AutomationRun, - AutomationSchedulerOwner, - AutomationRunTrigger, - AutomationUpdateInput -} from '../shared/automations-types' -import { - latestAutomationOccurrenceAtOrBefore, - nextAutomationOccurrenceAfter -} from '../shared/automation-schedules' -import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' -import { normalizeAutomationPrecheck } from '../shared/automation-precheck' -import { normalizeProxyUrl } from '../shared/network-proxy' -import { normalizeKagiSessionLink } from '../shared/browser-url' -import type { FolderWorkspace, WorkspaceKey } from '../shared/folder-workspace-types' -import type { GlobalSettings, OrcaWorkspaceLayout } from '../shared/global-settings-types' -import type { NotificationSettings } from '../shared/notification-settings-types' -import type { - OnboardingChecklistState, - OnboardingOutcome, - OnboardingState -} from '../shared/onboarding-state-types' -import type { - LegacyPaneKeyAliasEntry, - PersistedMobileClientTabSelections, - PersistedState -} from '../shared/persisted-state-types' -import type { ProjectGroup } from '../shared/project-group-types' -import type { - Project, - ProjectHostSetup, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - ProjectUpdateArgs, - RepoProjectHostSetupMethod -} from '../shared/project-types' -import type { Repo } from '../shared/repo-types' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TerminalTab -} from '../shared/terminal-tab-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../shared/workspace-session-state-types' -import type { SparsePreset } from '../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../shared/worktree/meta-types' -import { - deriveGlobalWindowsRuntimeDefaultFromLegacySettings, - normalizeProjectRuntimePreference -} from '../shared/project-execution-runtime' -import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' -import { carryProjectStateThroughIdentityChange } from '../shared/project-identity-succession' -import { isPluginPanelTabKey } from '../shared/plugins/plugin-manifest' -import type { GitRemoteIdentity } from '../shared/git-remote-identity' -import { - areTaskSourceContextsEqual, - buildTaskSourceContextFromRepo, - buildWorkspaceRunContext, - normalizeStoredTaskSourceContext -} from '../shared/task-source-context' -import { - areWorkspaceLinkedItemsEqual, - normalizeWorkspaceLinkedItem -} from '../shared/workspace-linked-item' -import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-linked-item-source-context' -import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' -import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' -import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' -import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' -import { - removeRepoFromHostWorkspaceSessions, - removeRepoFromWorkspaceSession -} from './orca-profiles/profile-project-session-state' -import { hardenExistingSecureFile } from '../shared/secure-file' -import { - LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - type RemovedSshTargetTombstone, - type SshPtyConsumerRecovery, - type SshRemotePtyLease, - type SshTarget -} from '../shared/ssh-types' -import { isFolderRepo } from '../shared/repo-kind' -import { - getRepoExecutionHostId, - parseExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostOrder, - normalizeExecutionHostId, - normalizeVisibleExecutionHostIds, - toSshExecutionHostId, - type ExecutionHostId -} from '../shared/execution-host' -import { - getDefaultPersistedState, - getDefaultNotificationSettings, - getDefaultOnboardingState, - getDefaultVoiceSettings, - getDefaultUIState, - getDefaultRepoHookSettings, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - isDefaultedCompactWorktreeCardProperties, - normalizeAgentActivityDisplayMode, - normalizeWorktreeCardProperties, - ONBOARDING_FLOW_VERSION, - ONBOARDING_FINAL_STEP -} from '../shared/constants' -import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' -import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' -import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' -import { - normalizeCustomWorktreeVisibilitySources, - normalizeWorktreeVisibilitySourcePreferences -} from '../shared/worktree/visibility-sources' -import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' -import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' -import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' -import { toRelaySshPtyId } from './providers/ssh-pty-id' -import { - migrateUiHostScopeSshTargetId, - migrateWorkspaceSessionSshTargetId -} from './ssh/ssh-target-id-migration' -import { isWslUncPath } from '../shared/wsl-paths' -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 { - backfillAutomationRunNumbers, - nextAutomationRunNumber, - pruneAutomationRuns -} from '../shared/automation-run-retention' -import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' -import { normalizeRetirableGeneratedName } from './worktree-name-retirement' -import { - addRetiredNames, - clampExhaustedTiers, - compactRetiredNames, - EMPTY_RETIRED_NAME_REGISTRY, - isEmptyRetiredNameRegistry, - type RetiredNameRegistry -} from '../shared/worktree/retired-name-registry' -import { - FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId, - getWorktreePathBasenameFromId -} from '../shared/worktree/id' -import { - isPathInsideOrEqual, - isWindowsAbsolutePathLike, - normalizeRuntimePathForComparison -} from '../shared/cross-platform-path' -import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' -import { normalizeTaskProviderSettings } from '../shared/task-providers' -import { mergeWorkspaceCleanupUIState } from '../shared/workspace-cleanup-ui-state' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings' -import { - addMobilePairingCustomAddress, - normalizeMobilePairingCustomAddress, - normalizeMobilePairingCustomAddresses -} from '../shared/mobile-pairing-custom-address' -import { normalizeOpenInApplications } from '../shared/open-in-applications' -import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' -import { normalizeAppIconId } from '../shared/app-icon' -import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' -import { - legacyTerminalScrollbackBytesToRows, - normalizeDesktopTerminalScrollbackRows -} from '../shared/terminal-scrollback-policy' -import { - compareFeatureInteractionUsageBuckets, - getFeatureInteractionCategory, - getFeatureInteractionUsageBucket, - normalizeFeatureInteractions, - normalizeFeatureInteractionTelemetryBuckets, - type FeatureInteractionId -} from '../shared/feature-interactions' -import { normalizeContextualTourIds } from '../shared/contextual-tours' -import { normalizeFeatureTipIds } from '../shared/feature-tips' -import { - parseCodexResetCreditAttemptLedger, - type CodexResetCreditAttemptLedger -} from '../shared/codex-reset-credit-attempt-ledger' -import { normalizeManualRepoOrder } from '../shared/manual-repo-order' -import { - DEFAULT_WORKSPACE_STATUS_ID, - clampWorkspaceBoardColumnWidth, - clampWorkspaceBoardOpacity, - normalizePersistedWorkspaceStatuses, - normalizeWorkspaceStatuses -} from '../shared/workspace-statuses' -import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width' -import { clampCombinedDiffFileTreeWidth } from '../shared/combined-diff-file-tree-width' -import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree/ownership' -import { - migrateExternalWorktreeVisibilityDefaults, - normalizeWorktreeVisibilityDefaults -} from '../shared/external-worktree-visibility' -import { sanitizeRepoIcon } from '../shared/repo-icon' -import { normalizeRepoBadgeColor } from '../shared/repo-badge-color' -import { - clearMissingProjectGroupMemberships, - createProjectGroup, - getNextProjectGroupOrder, - getProjectGroupSubtreeIds, - normalizeProjectGroupName, - normalizeProjectGroups -} from '../shared/project-groups' -import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import' -import { - mergeLegacyCommitMessageAiIntoSourceControlAi, - normalizeRepoSourceControlAiOverrides, - normalizeSourceControlAiSettings, - projectSourceControlAiToLegacyCommitMessageAi, - sourceControlAiSettingsFromLegacy -} from '../shared/source-control-ai' -import { - DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES, - SOURCE_CONTROL_TEXT_ACTION_IDS -} from '../shared/source-control-ai-actions' -import { normalizeDisabledTuiAgents } from '../shared/tui-agent-selection' -import { - DEFAULT_TUI_AGENT_ARGS, - DEFAULT_TUI_AGENT_ENV, - hasUnsupportedTuiAgentArgs, - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../shared/tui-agent-launch-defaults' -import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../shared/osc52-clipboard-settings' -import { normalizeTerminalLineHeight } from '../shared/terminal-line-height-settings' -import { normalizeUiLanguage } from '../shared/ui-language' -import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom' -import { persistedUIValuesEqual } from '../shared/persisted-ui-equality' -import { ActiveViewPreference } from './active-view-preference' -import { - collectFolderWorkspaceDiffComments, - normalizeFolderWorkspaceDiffComments -} from './folder-workspace-diff-comments' -import { - normalizeFolderWorkspaceName, - normalizeFolderWorkspaces -} from '../shared/folder-workspaces' -import { - folderWorkspaceKey, - isWorkspaceKey, - parseWorkspaceKey, - worktreeWorkspaceKey -} from '../shared/workspace-scope' -import { - collectTerminalScrollbackSnapshotRefs, - deleteTerminalScrollbackSnapshotSync, - getProfileTerminalScrollbackSnapshotRoot, - migrateWorkspaceSessionTerminalScrollbackSnapshots, - readTerminalScrollbackSnapshotSync, - type TerminalScrollbackSnapshotStorage -} from './terminal-scrollback-snapshots' -import { - deleteRemovedTerminalScrollbackSnapshotsAsync, - migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync -} from './terminal-scrollback-snapshot-async-migration' -import { track } from './telemetry/client' -import { getCohortAtEmit } from './telemetry/cohort-classifier' -import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' -import { - PROTECTED_SECRET_SLOT, - ProtectedSecretPersistence, - sshPtyOwnerLeaseSecretSlot, - type ProtectedSecretRetentionUpdate -} from './protected-secret-persistence' - -function isLegacyOpenCodeSessionCookie(value: string): boolean { - const trimmed = value.trim() - return ( - trimmed.startsWith('Fe26.2**') || - trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim())) - ) -} - -function isLegacySshPtyOwnerLease(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) -} - -function retireLegacyInstructionsForClearedTextActionRecipes( - sourceControlAi: GlobalSettings['sourceControlAi'], - previousSettings: GlobalSettings -): GlobalSettings['sourceControlAi'] { - if (!sourceControlAi?.actions) { - return sourceControlAi - } - - const previousSourceControlAi = normalizeSourceControlAiSettings( - previousSettings.sourceControlAi, - previousSettings.commitMessageAi - ) - let instructionsByOperation = sourceControlAi.instructionsByOperation - let changed = false - for (const actionId of SOURCE_CONTROL_TEXT_ACTION_IDS) { - if ( - sourceControlAi.actions[actionId]?.commandInputTemplate !== - DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] - ) { - continue - } - if ( - previousSourceControlAi.actions?.[actionId]?.commandInputTemplate === - DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] || - instructionsByOperation?.[actionId] !== - previousSourceControlAi.instructionsByOperation[actionId] - ) { - continue - } - if (instructionsByOperation?.[actionId] === '') { - continue - } - // Why: {basePrompt} is the explicit clear state; an empty instruction shadows rollback commitMessageAi.customPrompt on normalize/project. - instructionsByOperation = { ...instructionsByOperation, [actionId]: '' } - changed = true - } - - return changed ? { ...sourceControlAi, instructionsByOperation } : sourceControlAi -} - -// Why capture once (not a module const, not per-call): a const resolves before configureDevUserDataPath() redirects userData (dev/prod collide); -// per-call resolves after app.setName('Orca') flips path case and loses data on case-sensitive FS. index.ts calls initDataPath() at the right moment. -let _dataFile: string | null = null -let _userDataDir: string | null = null - -export function initDataPath(): void { - const userDataDir = app.getPath('userData') - _userDataDir = userDataDir - _dataFile = join(userDataDir, 'orca-data.json') -} - -function getDataFile(): string { - if (!_dataFile) { - // Safety fallback — should not be hit in normal startup. - const userDataDir = app.getPath('userData') - _userDataDir = userDataDir - _dataFile = join(userDataDir, 'orca-data.json') - } - return _dataFile -} - -// Why a sidecar: githubCache refreshes every poll and would rewrite the whole multi-MB orca-data.json each cycle. -// Snapshotted best-effort at quit for instant badges next launch; safe to lose. -function getGithubCacheFile(dataFile = getDataFile()): string { - return join(dirname(dataFile), 'orca-github-cache.json') -} - -// Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). -// GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. -const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 -const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 - -function gcStaleWorktreeMeta(state: PersistedState): number { - // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. - state.worktreeMeta ??= {} - const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) - const projectIds = new Set((state.projects ?? []).map((project) => project.id)) - const now = Date.now() - let removed = 0 - for (const key of Object.keys(state.worktreeMeta)) { - // Why: folder-project workspace instances (keyed repoId::path::workspace:) ARE the workspace record, not a checkout row; skip them. - if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) { - continue - } - const separator = key.indexOf('::') - if (separator === -1) { - continue - } - const ownerId = key.slice(0, separator) - const worktreePath = key.slice(separator + 2) - const meta = state.worktreeMeta[key] - const repo = repoById.get(ownerId) - if (repo) { - if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { - continue - } - } else if (projectIds.has(ownerId)) { - // Project-owned metas keep their own project/host lifecycle; leave them alone. - continue - } - // Unowned entries (repo removed before metas were pruned) fall through to the same missing-path + idle-grace gate. - if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) { - continue - } - if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) { - continue - } - // Why: WSL worktrees on Windows carry Linux-style paths that Windows existsSync can't probe and would falsely condemn. - if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) { - continue - } - // Why keep timestamp-less entries: without timestamps we can't prove the 30-day grace elapsed (measured dead entries all had them). - // Grace is checked before existsSync so active entries skip the stat fan-out (and its slow-NFS tail). - const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0) - if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) { - continue - } - if (existsSync(worktreePath)) { - continue - } - delete state.worktreeMeta[key] - delete state.worktreeLineageById[key] - delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] - removed++ - } - return removed -} - -function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { - let changed = false - const rawWorktreeMeta = state.worktreeMeta as unknown - if ( - typeof rawWorktreeMeta !== 'object' || - rawWorktreeMeta === null || - Array.isArray(rawWorktreeMeta) - ) { - state.worktreeMeta = {} - changed = rawWorktreeMeta !== undefined - } - for (const [key, meta] of Object.entries(state.worktreeMeta)) { - // Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta - // keeps timestamp-less keys forever and every downstream consumer trusts the Record type. - if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { - delete state.worktreeMeta[key] - // Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would - // otherwise re-attach to a worktree recreated at the same repoId::path. - delete state.worktreeLineageById[key] - delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] - changed = true - continue - } - const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) - const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) - const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - linkedWorkItem, - sourceContext - ) - ? sourceContext - : null - if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { - meta.linkedWorkItem = linkedWorkItem - changed = true - } - if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) { - meta.linkedTaskSourceContext = linkedTaskSourceContext - changed = true - } - } - return changed -} - -function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { - try { - const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown - const isPlainRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) - if ( - isPlainRecord(parsed) && - isPlainRecord((parsed as { pr?: unknown }).pr) && - isPlainRecord((parsed as { issue?: unknown }).issue) - ) { - return parsed as PersistedState['githubCache'] - } - } catch { - // Missing or corrupt snapshot: start with an empty cache and refetch. - } - return null -} - -/** - * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves. - * - * Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices. - */ -export function getCanonicalUserDataPath(): string { - if (!_userDataDir) { - // Safety fallback — should not be hit in normal startup. - _userDataDir = app.getPath('userData') - } - return _userDataDir -} - -/** - * Copy legacy mobile pairing credentials into the canonical userData directory. - * - * Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key. - */ -export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { - const targetUserDataDir = getCanonicalUserDataPath() - if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) { - return - } - - const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({ - sourcePath: join(sourceUserDataDir, fileName), - targetPath: join(targetUserDataDir, fileName) - })) - if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) { - return - } - if (migrations.some(({ targetPath }) => existsSync(targetPath))) { - return - } - - mkdirSync(targetUserDataDir, { recursive: true }) - for (const { sourcePath, targetPath } of migrations) { - copyFileSync(sourcePath, targetPath) - // Why: copyFileSync drops Windows ACLs, so re-assert current-user-only on these credential copies (device tokens, E2EE key). - hardenExistingSecureFile(targetPath) - } -} - -// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 -const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ - 'tabsByWorktree', - 'terminalLayoutsByTabId' -]) - -function logPersistenceStartupMilestone( - event: string, - details: Record = {} -): void { - if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) - } -} - -function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { - return Object.keys(patch).some((key) => - WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) - ) -} - -function workspaceSessionSalvageLogDetails(result: { - droppedCount: number - droppedPaths: string[] -}): { count: number; fields: string[]; detailsTruncated: boolean } { - return { - count: result.droppedCount, - fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], - detailsTruncated: result.droppedCount > result.droppedPaths.length - } -} - -/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. - * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ -function parseWorkspaceSessionsByHostId( - raw: unknown, - defaults: WorkspaceSessionState -): { partitions: Partial>; repaired: boolean } { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { partitions: {}, repaired: raw !== undefined } - } - let repaired = false - const partitions: Partial> = {} - for (const [key, value] of Object.entries(raw as Record)) { - const hostId = normalizeExecutionHostId(key) - // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. - if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { - continue - } - const result = parseWorkspaceSessionSalvaging(value) - if (!result.ok) { - repaired = true - console.error( - `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, - result.error - ) - continue - } - if (result.droppedCount > 0) { - console.warn( - `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, - workspaceSessionSalvageLogDetails(result) - ) - repaired = true - } - partitions[hostId] = { ...defaults, ...result.value } - } - return { partitions, repaired } -} - -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} - -/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ -async function exists(path: string): Promise { - return access(path).then( - () => true, - () => false - ) -} - -function buildWorkspaceDirHistoryForUpdate( - current: GlobalSettings, - updates: Partial -): OrcaWorkspaceLayout[] | null { - if (!('workspaceDir' in updates) && !('nestWorkspaces' in updates)) { - return null - } - const nextPath = updates.workspaceDir ?? current.workspaceDir - const nextNestWorkspaces = updates.nestWorkspaces ?? current.nestWorkspaces - if ( - normalizeRuntimePathForComparison(nextPath) === - normalizeRuntimePathForComparison(current.workspaceDir) && - nextNestWorkspaces === current.nestWorkspaces - ) { - return null - } - - const previousLayout = { - path: current.workspaceDir, - nestWorkspaces: current.nestWorkspaces - } - const existing = current.workspaceDirHistory ?? [] - const next = [...existing] - const previousKey = getWorkspaceLayoutHistoryKey(previousLayout) - if (!next.some((layout) => getWorkspaceLayoutHistoryKey(layout) === previousKey)) { - next.push(previousLayout) - } - return next -} - -type LegacyTerminalScrollbackSettings = { - terminalScrollbackRows?: unknown - terminalScrollbackBytes?: unknown -} - -type RetiredGlobalSettings = { - terminalScrollbackBytes?: unknown - enableGitHubAttribution?: unknown -} - -const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3 - -function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings { - return settings && typeof settings === 'object' - ? (settings as LegacyTerminalScrollbackSettings) - : {} -} - -function stripRetiredGlobalSettings( - settings: Partial | undefined -): Partial { - const { - terminalScrollbackBytes: _legacyScrollbackBytes, - enableGitHubAttribution: _legacyGitHubAttribution, - ...rest - } = (settings ?? {}) as Partial & RetiredGlobalSettings - void _legacyScrollbackBytes - void _legacyGitHubAttribution - return rest -} - -function migrateTerminalScrollbackRows(settings: unknown): { - rows: number - needsSave: boolean -} { - const legacySettings = readLegacyTerminalScrollbackSettings(settings) - const hasRows = Object.hasOwn(legacySettings, 'terminalScrollbackRows') - const hasLegacyBytes = Object.hasOwn(legacySettings, 'terminalScrollbackBytes') - const rows = hasRows - ? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows) - : legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes) - - return { - rows, - needsSave: !hasRows || hasLegacyBytes || legacySettings.terminalScrollbackRows !== rows - } -} - -function migrateTerminalTuiScrollSensitivityDefault(settings: GlobalSettings | undefined): { - settings: Pick< - GlobalSettings, - 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' - > - needsSave: boolean -} { - const alreadyDefaultedToOne = settings?.terminalTuiScrollSensitivityDefaultedToOne === true - const current = settings?.terminalTuiScrollSensitivity - const shouldMoveInheritedDefault = - !alreadyDefaultedToOne && - (current === undefined || current === LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT) - const terminalTuiScrollSensitivity = shouldMoveInheritedDefault ? 1 : (current ?? 1) - - return { - settings: { - terminalTuiScrollSensitivity, - terminalTuiScrollSensitivityDefaultedToOne: true - }, - needsSave: !alreadyDefaultedToOne || current === undefined - } -} - -function getWorkspaceLayoutHistoryKey(layout: OrcaWorkspaceLayout): string { - return `${normalizeRuntimePathForComparison(layout.path)}:${layout.nestWorkspaces}` -} - -function migrateAgentYoloDefaults( - settings: GlobalSettings | undefined -): Pick { - const existingArgs = normalizeTuiAgentArgsRecord(settings?.agentDefaultArgs) - const existingEnv = normalizeTuiAgentEnvRecord(settings?.agentDefaultEnv) - if (settings?.agentYoloDefaultsMigrated === true) { - return { - agentDefaultArgs: existingArgs, - agentDefaultEnv: existingEnv, - agentYoloDefaultsMigrated: true - } - } - - const commandOverrides = settings?.agentCmdOverrides ?? {} - const migratedArgs = { ...existingArgs } - for (const [agent, args] of Object.entries(DEFAULT_TUI_AGENT_ARGS)) { - if (agent in migratedArgs) { - continue - } - if (agent in commandOverrides) { - migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = '' - continue - } - migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = args - } - - const migratedEnv = { ...existingEnv } - for (const [agent, env] of Object.entries(DEFAULT_TUI_AGENT_ENV)) { - if (agent in migratedEnv) { - continue - } - if (agent in commandOverrides) { - migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = {} - continue - } - migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = { ...env } - } - - return { - // Why: legacy users could only customize launch defaults via command overrides, so those agents count as already user-owned. - agentDefaultArgs: migratedArgs, - agentDefaultEnv: migratedEnv, - agentYoloDefaultsMigrated: true - } -} - -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 normalizeShowDotfilesByWorktree(value: unknown): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const out: Record = {} - for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { - if ( - !worktreeId || - worktreeId === '__proto__' || - worktreeId === 'constructor' || - worktreeId === 'prototype' || - typeof showDotfiles !== 'boolean' - ) { - continue - } - out[worktreeId] = showDotfiles - } - return out -} - -function mergeFeatureInteractions( - current: PersistedState['ui']['featureInteractions'], - incoming: PersistedState['ui']['featureInteractions'] -): PersistedState['ui']['featureInteractions'] { - const currentNormalized = normalizeFeatureInteractions(current) - const incomingNormalized = normalizeFeatureInteractions(incoming) - const merged = { ...currentNormalized } - for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { - const currentRecord = currentNormalized[id as keyof typeof currentNormalized] - merged[id as keyof typeof merged] = currentRecord - ? { - firstInteractedAt: Math.min( - currentRecord.firstInteractedAt, - incomingRecord.firstInteractedAt - ), - interactionCount: Math.max( - currentRecord.interactionCount, - incomingRecord.interactionCount - ) - } - : incomingRecord - } - return merged -} - -function mergeContextualTourSeenIds( - current: PersistedState['ui']['contextualToursSeenIds'], - incoming: PersistedState['ui']['contextualToursSeenIds'] -): PersistedState['ui']['contextualToursSeenIds'] { - const merged = new Set(normalizeContextualTourIds(current)) - for (const id of normalizeContextualTourIds(incoming)) { - merged.add(id) - } - return [...merged] -} - -function stripMainOwnedTelemetryMarkerFromUI( - value: Partial | undefined -): Partial { - if (!value || typeof value !== 'object') { - return {} - } - const { featureInteractionTelemetryBuckets: _reserved, ...ui } = value as Partial< - PersistedState['ui'] - > & { - featureInteractionTelemetryBuckets?: unknown - } - void _reserved - return ui -} - -function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { - if ( - sortBy === 'smart' || - sortBy === 'recent' || - sortBy === 'repo' || - sortBy === 'name' || - sortBy === 'manual' - ) { - return sortBy - } - return getDefaultUIState().sortBy -} - -function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui']['projectOrderBy'] { - if (projectOrderBy === 'manual' || projectOrderBy === 'recent') { - return projectOrderBy - } - return getDefaultUIState().projectOrderBy -} - -export function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { - if ( - tab === 'explorer' || - tab === 'search' || - tab === 'vault' || - tab === 'workspaces' || - tab === 'pr-checks' || - tab === 'source-control' || - tab === 'checks' || - tab === 'ports' - ) { - return tab - } - // Why: plugin tabs are open-ended `plugin:./` keys; validate the - // shape so a persisted plugin tab doesn't reset to Explorer on restart. - if (typeof tab === 'string' && isPluginPanelTabKey(tab)) { - return tab - } - return getDefaultUIState().rightSidebarTab -} - -function normalizeWorkspaceLineageByChildKey( - value: unknown -): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const normalized: Record = {} - for (const [key, entry] of Object.entries(value)) { - if (!isWorkspaceKey(key) || !entry || typeof entry !== 'object') { - continue - } - const lineage = entry as Partial - const childWorkspaceKey = - typeof lineage.childWorkspaceKey === 'string' && isWorkspaceKey(lineage.childWorkspaceKey) - ? lineage.childWorkspaceKey - : key - const parentWorkspaceKey = lineage.parentWorkspaceKey - if ( - !isWorkspaceKey(childWorkspaceKey) || - typeof parentWorkspaceKey !== 'string' || - !isWorkspaceKey(parentWorkspaceKey) || - childWorkspaceKey !== key || - childWorkspaceKey === parentWorkspaceKey - ) { - continue - } - normalized[childWorkspaceKey] = { - childWorkspaceKey, - childInstanceId: lineage.childInstanceId ?? null, - parentWorkspaceKey, - parentInstanceId: lineage.parentInstanceId ?? null, - origin: lineage.origin ?? 'cli', - capture: lineage.capture ?? { source: 'manual-action', confidence: 'inferred' }, - ...(lineage.taskId ? { taskId: lineage.taskId } : {}), - ...(lineage.orchestrationRunId ? { orchestrationRunId: lineage.orchestrationRunId } : {}), - ...(lineage.coordinatorHandle ? { coordinatorHandle: lineage.coordinatorHandle } : {}), - ...(lineage.createdByTerminalHandle - ? { createdByTerminalHandle: lineage.createdByTerminalHandle } - : {}), - createdAt: Number.isFinite(lineage.createdAt) ? Number(lineage.createdAt) : Date.now() - } - } - return normalized -} - -function normalizeRightSidebarExplorerView( - view: unknown, - tab?: unknown -): PersistedState['ui']['rightSidebarExplorerView'] { - // Why: older builds persisted Search as a standalone activity tab. - if (tab === 'search') { - return 'search' - } - if (view === 'files' || view === 'search') { - return view - } - return getDefaultUIState().rightSidebarExplorerView -} - -function normalizeNotificationSettings(value: unknown): NotificationSettings { - const defaults = getDefaultNotificationSettings() - const candidate = - value && typeof value === 'object' ? (value as Partial) : {} - const rawSoundId = (candidate as { customSoundId?: unknown }).customSoundId - const customSoundId = - rawSoundId === 'system' || - rawSoundId === 'two-tone' || - rawSoundId === 'bong' || - rawSoundId === 'thump' || - rawSoundId === 'blip' || - rawSoundId === 'sonar' || - rawSoundId === 'blop' || - rawSoundId === 'ding' || - rawSoundId === 'clack' || - rawSoundId === 'beep' || - rawSoundId === 'custom' - ? rawSoundId - : rawSoundId === 'orca' || rawSoundId === 'chime' - ? 'two-tone' - : rawSoundId === 'pop' - ? 'blop' - : typeof candidate.customSoundPath === 'string' - ? 'custom' - : defaults.customSoundId - const rawVolume = candidate.customSoundVolume - const customSoundVolume = - typeof rawVolume === 'number' && Number.isFinite(rawVolume) - ? Math.min(100, Math.max(0, rawVolume)) - : defaults.customSoundVolume - return { - ...defaults, - ...candidate, - customSoundId, - customSoundVolume - } -} - -function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} - -function normalizeAutomationRunTerminalPaneKey(value: string | null | undefined): string | null { - const trimmed = typeof value === 'string' ? value.trim() : '' - return trimmed && parsePaneKey(trimmed) ? trimmed : null -} - -function normalizeAutomationRunTerminalPtyId(value: string | null | undefined): string | null { - const trimmed = typeof value === 'string' ? value.trim() : '' - return 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 - } -} - -function normalizeAutomationPrecheckResult( - value: AutomationPrecheckResult | null | undefined -): AutomationPrecheckResult | null { - if (!value || typeof value.command !== 'string' || !value.command.trim()) { - return null - } - const startedAt = - typeof value.startedAt === 'number' && Number.isFinite(value.startedAt) - ? value.startedAt - : Date.now() - const completedAt = - typeof value.completedAt === 'number' && Number.isFinite(value.completedAt) - ? value.completedAt - : startedAt - return { - command: value.command.trim(), - exitCode: - typeof value.exitCode === 'number' && Number.isFinite(value.exitCode) ? value.exitCode : null, - timedOut: value.timedOut === true, - durationMs: - typeof value.durationMs === 'number' && Number.isFinite(value.durationMs) - ? Math.max(0, value.durationMs) - : Math.max(0, completedAt - startedAt), - stdout: typeof value.stdout === 'string' ? value.stdout : '', - stderr: typeof value.stderr === 'string' ? value.stderr : '', - stdoutTruncated: value.stdoutTruncated === true, - stderrTruncated: value.stderrTruncated === true, - error: typeof value.error === 'string' && value.error.trim() ? value.error : null, - startedAt, - completedAt - } -} - -function normalizeAutomationSessionReuse(automation: Automation): Automation { - const setupDecision = normalizeAutomationSetupDecisionForWorkspaceMode( - automation.workspaceMode, - automation.setupDecision - ) - return { - ...automation, - precheck: normalizeAutomationPrecheck(automation.precheck), - setupDecision, - reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true - } -} - -function normalizeAutomationSetupDecisionForWorkspaceMode( - workspaceMode: Automation['workspaceMode'], - setupDecision: unknown -): Automation['setupDecision'] { - return workspaceMode === 'new_per_run' && (setupDecision === 'run' || setupDecision === 'skip') - ? setupDecision - : undefined -} - -function getAutomationContextsForRepo( - repo: Repo | undefined, - projectHostSetups: readonly ProjectHostSetup[] -): Pick { - if (!repo) { - return { - runContext: null, - sourceContext: null - } - } - const projection = projectHostSetupProjectionFromRepos([repo]) - const projectedProject = projection.projects[0] - const projectedSetup = projection.setups[0] - const setup = - projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup - const runContext = setup - ? buildWorkspaceRunContext({ - projectId: setup.projectId, - hostId: setup.hostId, - projectHostSetupId: setup.id, - repoId: repo.id, - path: setup.path - }) - : null - const providerIdentity = projectedProject?.providerIdentity - const sourceContext = providerIdentity - ? buildTaskSourceContextFromRepo({ - provider: providerIdentity.provider, - projectId: providerIdentity.provider === 'github' ? (setup?.projectId ?? repo.id) : repo.id, - repo, - projectHostSetupId: setup?.id, - providerIdentity - }) - : null - return { - runContext, - sourceContext - } -} - -function getAutomationSchedulerOwner(repo: Repo | undefined): AutomationSchedulerOwner { - if (!repo) { - return 'local_host_service' - } - const host = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (host?.kind === 'ssh') { - return 'ssh_bridge' - } - if (host?.kind === 'runtime') { - return 'remote_host_service' - } - return 'local_host_service' -} - -function backfillLegacyAutomationContexts( - state: Pick -): { - state: Pick - changed: boolean -} { - let changed = false - const contextsByAutomationId = new Map>() - const automations = (state.automations ?? []).map((automation) => { - const contexts = getAutomationContextsForRepo( - state.repos.find((repo) => repo.id === getAutomationLegacyRepoId(automation)), - state.projectHostSetups ?? [] - ) - const next: Automation = { ...automation } - if (!Object.hasOwn(next, 'runContext')) { - // Why: pre-host-context automations only stored a repo id; backfill the run target once so dispatch/precheck stop inferring it. - next.runContext = contexts.runContext - changed = true - } - if (!Object.hasOwn(next, 'sourceContext')) { - next.sourceContext = contexts.sourceContext - changed = true - } - contextsByAutomationId.set(next.id, { - runContext: next.runContext ?? null, - sourceContext: next.sourceContext ?? null - }) - return next - }) - const automationRuns = (state.automationRuns ?? []).map((run) => { - const automationContexts = contextsByAutomationId.get(run.automationId) - const next: AutomationRun = { ...run } - if (!Object.hasOwn(next, 'runContext')) { - next.runContext = automationContexts?.runContext ?? null - changed = true - } - if (!Object.hasOwn(next, 'sourceContext')) { - next.sourceContext = automationContexts?.sourceContext ?? null - changed = true - } - if (!Object.hasOwn(next, 'terminalPaneKey')) { - next.terminalPaneKey = null - changed = true - } - if (!Object.hasOwn(next, 'terminalPtyId')) { - next.terminalPtyId = null - changed = true - } - return next - }) - if (!changed) { - return { state, changed: false } - } - return { - state: { - ...state, - automations, - automationRuns - }, - changed: true - } -} - -type LegacySshTarget = SshTarget & { - remoteWorkspaceSyncEnabled?: unknown - remoteWorkspaceSyncGracePeriodSeconds?: unknown - experimentalPtySourceCreditV1?: unknown -} - -// Why: old targets predate configHost; default to label-based lookup so imported SSH aliases still resolve via ssh -G. -function normalizeSshTarget(t: SshTarget): SshTarget { - const target = { ...(t as LegacySshTarget) } - const legacySyncEnabled = target.remoteWorkspaceSyncEnabled - const currentGracePeriodSeconds = target.relayGracePeriodSeconds - const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds - const systemSshConnectionReuse = target.systemSshConnectionReuse - // Why: remote sync now follows the SSH relay lifecycle, so retired per-target sync/grace fields are dropped at disk load. - delete target.remoteWorkspaceSyncEnabled - delete target.remoteWorkspaceSyncGracePeriodSeconds - delete target.relayGracePeriodSeconds - delete target.systemSshConnectionReuse - delete target.experimentalPtySourceCreditV1 - // Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration. - const relayGracePeriodSeconds = - legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number' - ? legacyGracePeriodSeconds - : currentGracePeriodSeconds - const normalized: SshTarget = { - ...target, - configHost: target.configHost ?? target.label ?? target.host - } - // Why: old SSH form persisted 10800 even without a user choice; treat that legacy default as the new implicit default. - if ( - relayGracePeriodSeconds !== undefined && - relayGracePeriodSeconds !== LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS - ) { - normalized.relayGracePeriodSeconds = relayGracePeriodSeconds - } - if (systemSshConnectionReuse === false) { - normalized.systemSshConnectionReuse = false - } - return normalized -} - -// Why: strict whitelist rejects unknown/bad-typed keys; returns Partial so partial updates don't clobber valid persisted state. -type SanitizeOnboardingUpdateOptions = { - migrateLegacyProgress?: boolean -} - -function remapLegacyOnboardingLastCompletedStep( - lastCompletedStep: number, - raw: Record -): number { - if (raw.outcome === 'completed' && lastCompletedStep >= 4) { - return ONBOARDING_FINAL_STEP - } - // Why: v3 (pre-Windows-terminal-page) step 4 already meant notifications, so resume there, not the inserted Windows step. - if (raw.flowVersion === 3) { - return Math.min(4, lastCompletedStep) - } - // Why: v2's five-step flow had step 4 = removed agent setup, not completed integrations. - if (raw.flowVersion === 2) { - if (lastCompletedStep === 3) { - return 2 - } - if (lastCompletedStep >= 4) { - return 3 - } - return lastCompletedStep - } - if (lastCompletedStep === 3) { - return 2 - } - if (lastCompletedStep === 4) { - return 2 - } - if (lastCompletedStep >= 5) { - return 3 - } - return lastCompletedStep -} - -export function sanitizeOnboardingUpdate( - input: unknown, - options: SanitizeOnboardingUpdateOptions = {} -): Partial> & { checklist?: Partial } { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - return {} - } - const raw = input as Record - const out: Partial> & { - checklist?: Partial - } = {} - - if ('closedAt' in raw) { - // Why: NaN/Infinity serialize to null on save, reverting closedAt and reopening the wizard; require a finite timestamp. - 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 ('flowVersion' in raw) { - const v = raw.flowVersion - if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= ONBOARDING_FLOW_VERSION) { - out.flowVersion = v - } - // else: omit. - } - if ('lastCompletedStep' in raw) { - const v = raw.lastCompletedStep - if (typeof v === 'number' && Number.isInteger(v) && v >= -1) { - const isLegacyFlow = - options.migrateLegacyProgress && raw.flowVersion !== ONBOARDING_FLOW_VERSION - // Why: removing two wizard pages changed step numbering; migrate legacy values before the final-step bound drops them. - const normalized = isLegacyFlow ? remapLegacyOnboardingLastCompletedStep(v, raw) : v - if (normalized <= ONBOARDING_FINAL_STEP) { - out.lastCompletedStep = normalized - } - } - // 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 don't reset other checklist items to false. - const defaults = getDefaultOnboardingState().checklist - const rc = rawChecklist as Record - const checklist: Partial = {} - 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 - } - } - if (options.migrateLegacyProgress) { - out.flowVersion = ONBOARDING_FLOW_VERSION - } - return out -} - -function normalizeLoadedOnboardingState( - input: unknown, - defaults: OnboardingState -): OnboardingState { - // Why: an existing file with no onboarding block is an upgrade user; backfill as completed so they skip the wizard. - if (!input) { - return { - ...defaults, - closedAt: Date.now(), - outcome: 'completed', - lastCompletedStep: ONBOARDING_FINAL_STEP - } - } - // Why: sanitize persisted onboarding keys so a type-flipped field on disk can't poison in-memory state. - const sanitized = sanitizeOnboardingUpdate(input, { - migrateLegacyProgress: true - }) - // Why: a completed/dismissed outcome means the user left; recover a bad closedAt instead of reopening the checklist. - const recoveredClosedAt = - typeof sanitized.closedAt === 'number' - ? sanitized.closedAt - : sanitized.outcome !== null && sanitized.outcome !== undefined - ? Date.now() - : sanitized.closedAt - return { - ...defaults, - ...sanitized, - closedAt: recoveredClosedAt ?? defaults.closedAt, - checklist: { - ...defaults.checklist, - ...sanitized.checklist - } - } -} - -function resolveSetupGuideSidebarDismissedOnLoad( - persistedDismissed: unknown, - onboarding: OnboardingState -): boolean { - // Why: once onboarding is closed, persisted false is just the old default, not a user opt-in to the sidebar checklist. - return onboarding.closedAt !== null || persistedDismissed === true -} - -// Why: read a settings field removed from GlobalSettings but still on disk; one-shot for the inline-agents migration. -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 sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { - if (value === undefined) { - return undefined - } - if (value === null) { - return null - } - if (!value || typeof value !== 'object') { - return undefined - } - const candidate = value as { owner?: unknown; repo?: unknown; host?: unknown } - const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : '' - const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : '' - if (!owner || !repo) { - return undefined - } - // Why: an `upstream` remote may live on a different server than `origin`, so - // dropping the host forced consumers to re-infer it from origin and could bind - // a GHES parent to a same-named github.com repo. Absent host stays absent so - // records written before this survive unchanged. - const host = typeof candidate.host === 'string' ? candidate.host.trim() : '' - return host ? { owner, repo, host } : { owner, repo } -} - -function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined { - // Why: `null` is a resolved "no usable remote" marker; dropping it would make - // a settled repo indistinguishable from one whose identity probe is pending. - if (value === null) { - return null - } - if (!value || typeof value !== 'object') { - return undefined - } - const candidate = value as { - canonicalKey?: unknown - remoteName?: unknown - remoteUrl?: unknown - } - const canonicalKey = - typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : '' - const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : '' - const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : '' - return canonicalKey && remoteName && remoteUrl - ? { canonicalKey, remoteName, remoteUrl } - : undefined -} - -function sanitizeRepoProjectHostSetupMethod( - value: unknown -): RepoProjectHostSetupMethod | undefined { - return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined -} - -function sanitizeForkSyncMode(value: unknown): Repo['forkSyncMode'] | undefined { - return value === 'ask' || value === 'safe-auto' || value === 'off' ? value : undefined -} - -function sanitizeRepoUpdatesForPersistence< - T extends Partial< - Pick< - Repo, - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'gitRemoteIdentity' - | 'worktreeBasePath' - | 'projectHostSetupMethod' - | 'forkSyncMode' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - > - > ->(updates: T): T { - const sanitized = { ...updates } - if ('badgeColor' in sanitized) { - const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) - if (!badgeColor) { - delete sanitized.badgeColor - } else { - sanitized.badgeColor = badgeColor - } - } - if ('repoIcon' in sanitized) { - const repoIcon = sanitizeRepoIcon(sanitized.repoIcon) - if (repoIcon === undefined) { - delete sanitized.repoIcon - } else { - sanitized.repoIcon = repoIcon - } - } - // Why: `null` is a valid "not a fork" / "no usable remote" marker; only drop malformed shapes. - if ('upstream' in sanitized) { - const upstream = sanitizeRepoUpstream(sanitized.upstream) - if (upstream === undefined) { - delete sanitized.upstream - } else { - sanitized.upstream = upstream - } - } - if ('gitRemoteIdentity' in sanitized) { - const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity) - if (gitRemoteIdentity === undefined) { - delete sanitized.gitRemoteIdentity - } else { - sanitized.gitRemoteIdentity = gitRemoteIdentity - } - } - if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { - if (typeof sanitized.worktreeBasePath === 'string') { - sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined - } else { - delete sanitized.worktreeBasePath - } - } - if ('projectHostSetupMethod' in sanitized) { - const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod) - if (setupMethod === undefined) { - delete sanitized.projectHostSetupMethod - } else { - sanitized.projectHostSetupMethod = setupMethod - } - } - if ('forkSyncMode' in sanitized) { - const forkSyncMode = sanitizeForkSyncMode(sanitized.forkSyncMode) - if (forkSyncMode === undefined) { - delete sanitized.forkSyncMode - } else { - sanitized.forkSyncMode = forkSyncMode - } - } - if ('customWorktreeVisibilitySources' in sanitized) { - const sources = normalizeCustomWorktreeVisibilitySources( - sanitized.customWorktreeVisibilitySources - ) - if (!sources) { - delete sanitized.customWorktreeVisibilitySources - } else { - sanitized.customWorktreeVisibilitySources = sources - } - } - if ('worktreeVisibilitySourcePreferences' in sanitized) { - const preferences = normalizeWorktreeVisibilitySourcePreferences( - sanitized.worktreeVisibilitySourcePreferences - ) - if (!preferences) { - delete sanitized.worktreeVisibilitySourcePreferences - } else { - sanitized.worktreeVisibilitySourcePreferences = preferences - } - } - return sanitized -} - -function expandFloatingWorkspaceHomePath(input: string, home: string): string { - if (input === '~') { - return home - } - if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) { - return join(home, input.slice(2)) - } - return input -} - -function resolveFloatingWorkspacePath(input: string, home: string): string { - const expanded = expandFloatingWorkspaceHomePath(input, home) - return isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded) -} - -function canonicalizePersistedFloatingWorkspaceDirectory( - input: string, - home: string -): string | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - try { - const canonicalPath = resolve(realpathSync(resolveFloatingWorkspacePath(trimmed, home))) - return statSync(canonicalPath).isDirectory() ? canonicalPath : null - } catch { - return null - } -} - -function normalizeFloatingWorkspaceTrustedCwds( - input: unknown, - home: string -): { trustedCwds: string[]; changed: boolean } { - const rawTrustedCwds = Array.isArray(input) ? input : [] - const trustedCwds: string[] = [] - const seen = new Set() - let changed = input !== undefined && !Array.isArray(input) - - for (const rawTrustedCwd of rawTrustedCwds) { - if (typeof rawTrustedCwd !== 'string') { - changed = true - continue - } - const trimmedTrustedCwd = rawTrustedCwd.trim() - if (!trimmedTrustedCwd) { - changed = true - continue - } - const canonicalPath = canonicalizePersistedFloatingWorkspaceDirectory(trimmedTrustedCwd, home) - const normalizedPath = canonicalPath ?? resolveFloatingWorkspacePath(trimmedTrustedCwd, home) - if (!normalizedPath) { - changed = true - continue - } - if (seen.has(normalizedPath)) { - changed = true - continue - } - seen.add(normalizedPath) - trustedCwds.push(normalizedPath) - if (rawTrustedCwd !== normalizedPath) { - changed = true - } - } - - return { trustedCwds, changed } -} - -function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { - if (!value || typeof value !== 'object') { - return null - } - const raw = value as Partial - 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 } : {}) - } -} - -const SSH_PTY_OWNER_LEASE_MAX_LENGTH = 512 -const ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH = 4096 - -function normalizeSshPtyConsumerRecovery( - value: unknown, - ownerLeaseMaxLength = SSH_PTY_OWNER_LEASE_MAX_LENGTH -): SshPtyConsumerRecovery | null { - if (!value || typeof value !== 'object') { - return null - } - const raw = value as Partial - const clientGeneration = raw.clientGeneration - const ownerGeneration = raw.ownerGeneration - if ( - typeof raw.targetId !== 'string' || - raw.targetId.length === 0 || - raw.targetId.length > 512 || - typeof raw.clientInstanceId !== 'string' || - raw.clientInstanceId.length === 0 || - raw.clientInstanceId.length > 512 || - typeof raw.serverBuildId !== 'string' || - raw.serverBuildId.length === 0 || - raw.serverBuildId.length > 512 || - typeof clientGeneration !== 'number' || - !Number.isSafeInteger(clientGeneration) || - clientGeneration <= 0 || - typeof ownerGeneration !== 'number' || - !Number.isSafeInteger(ownerGeneration) || - ownerGeneration <= 0 || - typeof raw.ownerLease !== 'string' || - raw.ownerLease.length === 0 || - raw.ownerLease.length > ownerLeaseMaxLength - ) { - return null - } - const flow = raw.outputFlowControl - const outputFlowControl = - flow?.version === 1 && Number.isSafeInteger(flow.windowSu) && flow.windowSu > 0 - ? { version: 1 as const, windowSu: flow.windowSu } - : undefined - return { - targetId: raw.targetId, - clientInstanceId: raw.clientInstanceId, - serverBuildId: raw.serverBuildId, - clientGeneration, - ownerGeneration, - ownerLease: raw.ownerLease, - ...(outputFlowControl ? { outputFlowControl } : {}) - } -} - -type LayoutLeafNormalization = { - snapshot: TerminalLayoutSnapshot - changed: boolean - leafIdByInputLeafId: Map -} - -function collectLayoutLeafCounts( - node: TerminalPaneLayoutNode, - counts = new Map() -): Map { - 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, - duplicatedInputLeafIds: Set -): 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 | undefined, - leafIdByInputLeafId: Map, - duplicatedInputLeafIds: Set -): Record | undefined { - if (!source) { - return undefined - } - const next: Record = {} - 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 | undefined, - right: Record | 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 | undefined, - incomingRecord: Record | undefined, - liveLeafIds: Set -): Record | 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 -}): 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() - 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: PaneManager ids are 1-based; a zero-based alias in split layouts makes tab:1 ambiguous and misroutes panes. - 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, not persisted as restart-required rows. - 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() - 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 rows lack the old numeric key; only synthesize single-pane aliases when the row is unambiguous. - 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 the UUID root was sync-flushed. - 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() - 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 scrollbackRefsByLeafId = remapLeafRecordForPersistence( - inputSnapshot.scrollbackRefsByLeafId, - leafIdByInputLeafId, - duplicatedInputLeafIds - ) - const titlesByLeafId = remapLeafRecordForPersistence( - inputSnapshot.titlesByLeafId, - leafIdByInputLeafId, - duplicatedInputLeafIds - ) - const recordsChanged = - !leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) || - !leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) || - !leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) || - !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, - scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, - titlesByLeafId: _oldTitlesByLeafId, - ...snapshotWithoutLeafRecords - } = inputSnapshot - return { - snapshot: { - ...snapshotWithoutLeafRecords, - root, - activeLeafId, - expandedLeafId, - ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), - ...(buffersByLeafId ? { buffersByLeafId } : {}), - ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), - ...(titlesByLeafId ? { titlesByLeafId } : {}) - }, - changed: true, - leafIdByInputLeafId - } -} - -function normalizeWorkspaceSessionPaneIdentities( - session: WorkspaceSessionState, - priorLayoutsByTabId: Record = {} -): { - session: WorkspaceSessionState - changed: boolean - leafIdByInputLeafIdByTabId: Map> - leafIdByPtyIdByTabId: Map> - migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] - legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] -} { - let changed = false - const leafIdByInputLeafIdByTabId = new Map>() - const leafIdByPtyIdByTabId = new Map>() - const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] - const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] - const terminalLayoutsByTabId: Record = {} - 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 - }) - // Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push(). - for (const entry of migrationEntries.migrationUnsupportedEntries) { - migrationUnsupportedEntries.push(entry) - } - for (const entry of migrationEntries.legacyPaneKeyAliasEntries) { - legacyPaneKeyAliasEntries.push(entry) - } - const leafIdByPtyId = new Map() - const duplicatePtyIds = new Set() - 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>, - leafIdByPtyIdByTabId: Map> -): { 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; don't 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> -): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { - if (!acknowledgements || Object.keys(acknowledgements).length === 0) { - return { acknowledgements, changed: false } - } - - let changed = false - const next: NonNullable = {} - 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: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread. - setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt) - changed = true - } catch { - setAcknowledgement(paneKey, acknowledgedAt) - } - } - - return { acknowledgements: next, changed } -} - -// Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive. -const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 - -// Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. -const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 - -/** Why: retirement never evicts — dropping an entry hands back a name whose agent conversation is - * still on disk — so the row is bounded by compaction instead, and a persisted row is a watermark - * plus whatever sits above it. Accepts the pre-compaction plain-array row too: the feature is - * unreleased, but a developer profile or a fixture can still hold one. */ -function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { - const isPlainArray = Array.isArray(row) - const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined - const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] - const names = new Set() - for (const entry of rawNames) { - if (typeof entry !== 'string') { - continue - } - const normalized = normalizeRetirableGeneratedName(entry) - if (normalized) { - names.add(normalized) - } - } - return compactRetiredNames({ - exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), - names: [...names] - }) -} - -/** Why: a corrupt or hand-edited map must degrade to "nothing retired" rather than throw during - * load — over-retiring costs one name from a 552-entry pool, but a load failure costs the app. */ -function normalizeRetiredNameRegistryMap(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const byRepo: Record = {} - for (const [repoId, row] of Object.entries(value as Record)) { - if (!repoId) { - continue - } - const registry = normalizeRetiredNameRegistry(row) - if (!isEmptyRetiredNameRegistry(registry)) { - byRepo[repoId] = registry - } - } - return byRepo -} - -function normalizeClaudeLivePtySessionIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - // Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy. - const ids: string[] = [] - for (let index = value.length - 1; index >= 0; index -= 1) { - const entry = value[index] - if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) { - continue - } - if (!ids.includes(entry)) { - ids.push(entry) - } - if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) { - break - } - } - return ids.toReversed() -} - -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 - 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 - 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 relocatedSource = parsePaneKey(candidate.legacyPaneKey) - const stable = parsePaneKey(candidate.stablePaneKey) - return Boolean(stable && ((legacy && legacy.tabId === stable.tabId) || relocatedSource)) - }) -} - -function registerPersistedPaneKeyAlias(entry: LegacyPaneKeyAliasEntry): void { - if (parseLegacyNumericPaneKey(entry.legacyPaneKey)) { - agentHookServer.registerPaneKeyAlias( - entry.legacyPaneKey, - entry.stablePaneKey, - entry.ptyId, - entry.updatedAt, - { overwriteExisting: false } - ) - return - } - // Why: detached agents keep their UUID pane key across restarts; restore the physical-to-owner mapping before hook replay. - agentHookServer.transferPaneAuthority( - entry.legacyPaneKey, - entry.stablePaneKey, - entry.ptyId, - entry.updatedAt, - { authorityVerified: false } - ) -} - -function mergeLegacyPaneKeyAliasEntries( - entries: LegacyPaneKeyAliasEntry[] -): LegacyPaneKeyAliasEntry[] { - const byLegacyPaneKey = new Map() - 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 projectHostSetupCompatibilityStateEqual( - state: Pick, - nextState: Pick -): boolean { - return ( - JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) && - JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups) - ) -} - -function isRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - currentRepoIds: ReadonlySet -): boolean { - const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' - return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) -} - -function mergeProjectHostSetupCompatibilityState( - state: Pick, - repos: readonly Repo[] -): Pick { - const projection = projectHostSetupProjectionFromRepos(repos) - const succession = carryProjectStateThroughIdentityChange( - projection.projects, - state.projects ?? [] - ) - const currentRepoIds = new Set(repos.map((repo) => repo.id)) - const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) - const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) - // Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts. - const independentSetups = (state.projectHostSetups ?? []) - .filter((setup) => { - if (projectedSetupIds.has(setup.id)) { - return false - } - return !isRepoBackedProjectHostSetup(setup, currentRepoIds) - }) - // Why: follow the repo's project through a derived-id change so no ghost project row survives. - .map((setup) => { - const remappedProjectId = succession.remappedProjectIds.get(setup.projectId) - return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup - }) - const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) - const independentProjects = (state.projects ?? []) - .filter( - (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) - ) - .map((project) => ({ - ...project, - sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) - })) - return { - projects: [...succession.projects, ...independentProjects], - projectHostSetups: [...projection.setups, ...independentSetups] - } -} - -function makeProjectHostSetupId( - projectId: string, - hostId: ExecutionHostId, - existingIds: ReadonlySet, - requestedId?: string -): string { - const baseId = requestedId?.trim() || `${projectId}::${hostId}` - if (!existingIds.has(baseId)) { - return baseId - } - let suffix = 2 - let candidate = `${baseId}::${suffix}` - while (existingIds.has(candidate)) { - suffix++ - candidate = `${baseId}::${suffix}` - } - return candidate -} - -function createMinimalPersistedTerminalTab(args: { - worktreeId: string - tabId: string - ptyId: string - existingTabCount: number - startupCwd?: string -}): 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(), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - pendingActivationSpawn: true - } -} - -function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSessionState { - return structuredClone(session) -} - -// Deletes the O(1) owner-keyed fields for `ownerKey` from an already-cloned -// session in place, recording removed tab ids into `removedTabIds`. The -// pane-key-scanned maps (pty incarnations, surface tombstones, sleeping agents) -// and the shutdown list are handled by deleteScannedSessionFieldsForOwners so a -// batch prune scans each collection once instead of once per owner. -function deleteOwnerKeyedSessionFields( - next: WorkspaceSessionState, - ownerKey: string, - removedTabIds: Set, - options: { advanceTerminalTopologyRevision?: boolean } = {} -): void { - const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? [] - if (next.tabsByWorktree) { - delete next.tabsByWorktree[ownerKey] - } - for (const tab of removedTerminalTabs) { - removedTabIds.add(tab.id) - delete next.terminalLayoutsByTabId[tab.id] - if (next.activeTabId === tab.id) { - next.activeTabId = null - } - } - if (options.advanceTerminalTopologyRevision) { - const repoId = getRepoIdFromWorktreeId(ownerKey) - const previousTopologyRevision = next.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - next.terminalTopologyRevisionByRepoId = { - ...next.terminalTopologyRevisionByRepoId, - [repoId]: previousTopologyRevision + 1 - } - } - if (next.openFilesByWorktree) { - delete next.openFilesByWorktree[ownerKey] - } - if (next.activeFileIdByWorktree) { - delete next.activeFileIdByWorktree[ownerKey] - } - const browserWorkspaces = next.browserTabsByWorktree?.[ownerKey] ?? [] - if (next.browserTabsByWorktree) { - delete next.browserTabsByWorktree[ownerKey] - } - if (next.browserPagesByWorkspace) { - for (const workspace of browserWorkspaces) { - delete next.browserPagesByWorkspace[workspace.id] - } - } - if (next.activeBrowserTabIdByWorktree) { - delete next.activeBrowserTabIdByWorktree[ownerKey] - } - if (next.activeTabTypeByWorktree) { - delete next.activeTabTypeByWorktree[ownerKey] - } - if (next.activeTabIdByWorktree) { - delete next.activeTabIdByWorktree[ownerKey] - } - if (next.unifiedTabs) { - delete next.unifiedTabs[ownerKey] - } - if (next.tabGroups) { - delete next.tabGroups[ownerKey] - } - if (next.tabGroupLayouts) { - delete next.tabGroupLayouts[ownerKey] - } - if (next.activeGroupIdByWorktree) { - delete next.activeGroupIdByWorktree[ownerKey] - } - if (next.lastVisitedAtByWorktreeId) { - delete next.lastVisitedAtByWorktreeId[ownerKey] - } - if (next.defaultTerminalTabsAppliedByWorktreeId) { - delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey] - } - if (next.activeWorkspaceKey === ownerKey) { - next.activeWorkspaceKey = null - } - if (next.activeWorktreeId === ownerKey) { - next.activeWorktreeId = null - } -} - -// Scans the pane-key-keyed maps and the shutdown list once, removing every entry -// owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose tab -// was removed). Kept separate from the O(1) deletes so a batch prune scans each -// collection a single time regardless of how many owners are being removed. -function deleteScannedSessionFieldsForOwners( - next: WorkspaceSessionState, - removedTabIds: ReadonlySet, - isRemovedOwner: (worktreeId: string) => boolean -): void { - if (next.terminalPtyIncarnationsByPaneKey) { - next.terminalPtyIncarnationsByPaneKey = Object.fromEntries( - Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => { - const separator = paneKey.lastIndexOf(':') - return separator < 1 || !removedTabIds.has(paneKey.slice(0, separator)) - }) - ) - } - if (next.terminalSurfaceTombstonesByPaneKey) { - next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( - Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( - ([, tombstone]) => !isRemovedOwner(tombstone.worktreeId) - ) - ) - } - if (next.sleepingAgentSessionsByPaneKey) { - for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) { - if (isRemovedOwner(record.worktreeId)) { - delete next.sleepingAgentSessionsByPaneKey[paneKey] - } - } - } - next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( - (worktreeId) => !isRemovedOwner(worktreeId) - ) -} - -// Remote (ssh:/runtime:) workspace state can exist in both the renderer's local blob and main's host -// partition, because the renderer falls back to 'local' whenever worktree ownership is unresolved. -function workspaceSessionPartitionIdsForHost(hostId: string | null | undefined): ExecutionHostId[] { - const parsed = parseExecutionHostId(hostId) - return parsed && parsed.id !== LOCAL_EXECUTION_HOST_ID - ? [LOCAL_EXECUTION_HOST_ID, parsed.id] - : [LOCAL_EXECUTION_HOST_ID] -} - -/** The partition the host actually owns; the others are only spill surfaces for it. */ -function workspaceSessionOwnerPartitionForHost(hostId: string | null | undefined): ExecutionHostId { - return parseExecutionHostId(hostId)?.id ?? LOCAL_EXECUTION_HOST_ID -} - -function removeWorkspaceSessionOwner( - session: WorkspaceSessionState | undefined, - ownerKey: string, - options: { advanceTerminalTopologyRevision?: boolean } = {} -): WorkspaceSessionState | undefined { - if (!session) { - return session - } - const next = cloneWorkspaceSessionState(session) - const removedTabIds = new Set() - deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds, options) - deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => worktreeId === ownerKey) - return next -} - -// Batch variant of removeWorkspaceSessionOwner: prunes every owner in `ownerKeys` -// with a single structuredClone and a single scan of each collection, instead of -// one clone+scan per owner. Project removal can touch many worktrees across many -// host partitions, so the per-owner clones added up to O(worktrees × hosts). -function removeWorkspaceSessionOwners( - session: WorkspaceSessionState | undefined, - ownerKeys: ReadonlySet -): WorkspaceSessionState | undefined { - if (!session || ownerKeys.size === 0) { - return session - } - const next = cloneWorkspaceSessionState(session) - const removedTabIds = new Set() - for (const ownerKey of ownerKeys) { - deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds) - } - deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => - ownerKeys.has(worktreeId) - ) - return next -} - -function inferFolderScopeConnectionIdForMigration(args: { - folderPath: string - projectGroupId: string - projectGroups: readonly ProjectGroup[] - repos: readonly Repo[] -}): string | null { - const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) - const groupRepos = args.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const candidateRepos = - groupRepos.length > 0 - ? groupRepos - : args.repos.filter((repo) => isPathInsideOrEqual(args.folderPath, repo.path)) - if (candidateRepos.length === 0) { - return null - } - let hasLocalRepo = false - const connectionIds = new Set() - for (const repo of candidateRepos) { - if (repo.connectionId) { - connectionIds.add(repo.connectionId) - } else { - hasLocalRepo = true - } - } - if (hasLocalRepo || connectionIds.size !== 1) { - return null - } - return [...connectionIds][0] -} - -function backfillFolderScopeConnectionIds(state: PersistedState): { - state: PersistedState - changed: boolean -} { - const groups = state.projectGroups ?? [] - const repos = state.repos ?? [] - let changed = false - const projectGroups = groups.map((group) => { - if (group.connectionId || !group.parentPath) { - return group - } - const connectionId = inferFolderScopeConnectionIdForMigration({ - folderPath: group.parentPath, - projectGroupId: group.id, - projectGroups: groups, - repos - }) - if (!connectionId) { - return group - } - changed = true - return { ...group, connectionId } - }) - const groupsById = new Map(projectGroups.map((group) => [group.id, group])) - const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => { - if (workspace.connectionId) { - return workspace - } - const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null - const connectionId = - groupConnectionId ?? - inferFolderScopeConnectionIdForMigration({ - folderPath: workspace.folderPath, - projectGroupId: workspace.projectGroupId, - projectGroups, - repos - }) - if (!connectionId) { - return workspace - } - changed = true - return { ...workspace, connectionId } - }) - return { - changed, - state: changed ? { ...state, projectGroups, folderWorkspaces } : state - } -} - -function deleteRemovedTerminalScrollbackSnapshots( - prior: WorkspaceSessionState | undefined, - next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage -): void { - if (!prior) { - return - } - const nextRefs = collectTerminalScrollbackSnapshotRefs(next) - for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - if (!nextRefs.has(ref)) { - deleteTerminalScrollbackSnapshotSync(ref, storage) - } - } -} - -export type StoreOptions = { - dataFile?: string -} - -export type PtyBindingSourceExpectation = { - worktreeId?: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string -} - -export class Store { - private state: PersistedState - private readonly dataFile: string - private readonly activeViewPreference: ActiveViewPreference - private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage - private writeTimer: ReturnType | null = null - private pendingWrite: Promise | null = null - private pendingSnapshotFileWork: Promise | null = null - private readonly staleTempCleanup: Promise - private writeGeneration = 0 - private inFlightAsyncTmpFile: string | null = null - // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. - private backupRotationInFlight = false - // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. - private writesFrozen = false - /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ - private quitFlushStarted = false - private quitFlushPromise: Promise | null = null - // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. - private lastWrittenStateHash: string | null = null - private lastDurableWriteGeneration = -1 - private firstPendingSaveAt: number | null = null - private githubCacheDirty = false - private githubCacheGeneration = 0 - private pendingGithubCacheWrite: Promise | null = null - private readonly staleGithubCacheTempCleanup: Promise - private gitUsernameCache = new Map() - private readonly protectedSecrets = new ProtectedSecretPersistence() - private loadNeedsSave = false - private settingsChangeListeners = new Set< - ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - >() - private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() - - constructor(options: StoreOptions = {}) { - // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. - this.dataFile = options.dataFile ?? getDataFile() - this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { - minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS - }) - this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( - getGithubCacheFile(this.dataFile), - { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } - ) - const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) - const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) - this.terminalScrollbackSnapshotStorage = { - snapshotRoot: profileSnapshotRoot, - fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot - } - const loaded = this.load() - const normalized = normalizePersistedPaneIdentityState(loaded) - this.state = normalized.state - // Why: activeView is a frequent, tiny preference; keeping it beside the - // profile avoids serializing the multi-MB recovery store on navigation. - this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) - const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() - this.hydrateFolderWorkspaceDiffComments() - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - setMigrationUnsupportedPtyPersistenceListener((entries) => { - this.state.migrationUnsupportedPtyEntries = entries - this.scheduleSave() - }) - agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { - this.state.legacyPaneKeyAliasEntries = entries - this.scheduleSave() - }) - if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { - // Why: rewrite legacy pane:1 leaves so older renderer writes can't revive them; other migrations also set loadNeedsSave. - this.scheduleSave() - } - } - - // Why: notes live top-level on disk so an older build's field-by-field - // normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here. - private hydrateFolderWorkspaceDiffComments(): void { - const stored = this.state.folderWorkspaceDiffComments - let relocatedInline = false - for (const workspace of this.state.folderWorkspaces ?? []) { - if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { - // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the - // older map untouched, so inline is the last notes-aware write. Also makes the relocation - // durable even if the user never edits anything this session. - relocatedInline = true - continue - } - const comments = stored?.[workspace.id] - // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. - if (Array.isArray(comments) && comments.length > 0) { - workspace.diffComments = comments - } - } - if (relocatedInline) { - this.loadNeedsSave = true - } - // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in - // state would make it a stale second source of truth that getDurableState() spreads back out. - delete this.state.folderWorkspaceDiffComments - } - - private adaptFlatFolderScanProjectGroups(): boolean { - // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. - const groups = this.state.projectGroups ?? [] - const repos = this.state.repos - if (groups.length === 0 || repos.length === 0) { - return false - } - - let changed = false - let maxOrder = -1 - for (const group of groups) { - maxOrder = Math.max(maxOrder, group.tabOrder) - } - - const childGroupIds = new Set( - groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) - ) - const initialGroupCount = groups.length - for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { - const rootGroup = groups[groupIndex] - if (!rootGroup) { - continue - } - if ( - rootGroup.createdFrom !== 'folder-scan' || - !rootGroup.parentPath || - rootGroup.parentGroupId || - childGroupIds.has(rootGroup.id) - ) { - continue - } - const rootPath = rootGroup.parentPath - const repoCandidates = repos.filter( - (repo) => - !isFolderRepo(repo) && - repo.projectGroupId === rootGroup.id && - isPathInsideOrEqual(rootPath, repo.path) - ) - if (repoCandidates.length < 2) { - continue - } - - const resolver = createNestedProjectGroupResolver({ - parentPath: rootPath, - groupName: rootGroup.name, - mode: 'group', - repoPaths: repoCandidates.map((repo) => repo.path), - createGroup: (input) => { - if (!input.parentGroupId) { - return rootGroup - } - maxOrder += 1 - const group = createProjectGroup({ - ...input, - tabOrder: maxOrder - }) - groups.push(group) - changed = true - return group - } - }) - const nextOrderByGroupId = new Map() - for (const repo of repoCandidates) { - const group = resolver.getGroupForRepo(repo.path) - if (!group) { - continue - } - const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 - nextOrderByGroupId.set(group.id, nextOrder + 1) - if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { - repo.projectGroupId = group.id - repo.projectGroupOrder = nextOrder - changed = true - } - } - } - return changed - } - - // Why (#1158): debounced writes fire ~every 300ms; throttle backups to distinct moments, not near-identical snapshots. - 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 separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible - // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. - private async shouldRotateBackupsAsync(dataFile: string): Promise { - try { - const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs - return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. - private async rotateBackupsAsync(dataFile: string): Promise { - if (this.backupRotationInFlight) { - return - } - this.backupRotationInFlight = true - try { - if (!(await this.shouldRotateBackupsAsync(dataFile))) { - return - } - if (!(await exists(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) - // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an - // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. - if (await exists(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) - }) - } finally { - this.backupRotationInFlight = false - } - } - - 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 "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. - const dataFile = this.dataFile - const fileExistedOnLoad = existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { - fileExists: fileExistedOnLoad - }) - - let result: PersistedState | null = null - try { - if (fileExistedOnLoad) { - const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - - // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. - if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - parsed.settings.opencodeSessionCookie, - isLegacyOpenCodeSessionCookie - ) - } - if (parsed.settings?.httpProxyUrl) { - const decryptedProxy = this.protectedSecrets.decryptWithStatus( - PROTECTED_SECRET_SLOT.httpProxyUrl, - parsed.settings.httpProxyUrl, - (value) => normalizeProxyUrl(value).ok - ) - // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL - // value must not masquerade as a configured proxy (silent DIRECT fallback) or - // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. - if ( - decryptedProxy.status === 'unavailable' || - (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) - ) { - parsed.settings.httpProxyUrl = '' - } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { - parsed.settings.httpProxyUrl = decryptedProxy.plaintext - } else { - console.warn( - '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' - ) - parsed.settings.httpProxyUrl = '' - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) - this.loadNeedsSave = true - } - } - if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - parsed.ui.browserKagiSessionLink, - (value) => normalizeKagiSessionLink(value) !== null - ) - } - parsed.sshPtyConsumerRecoveries = ( - Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] - ) - .map((record) => - normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) - ) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => { - const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) - const decrypted = this.protectedSecrets.decryptWithStatus( - slot, - record.ownerLease, - isLegacySshPtyOwnerLease - ) - const normalized = - decrypted.status === 'unavailable' || - (decrypted.status === 'failed' && !decrypted.plaintext) - ? record - : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) - if (!normalized) { - this.protectedSecrets.removeRetainedBlob(slot) - } - return normalized - }) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - - // Merge with defaults in case new fields were added - const homeDir = homedir() - const defaults = getDefaultPersistedState(homeDir) - const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( - Array.isArray(parsed.repos) ? parsed.repos : [], - parsed.settings?.worktreeVisibilityDefaults - ) - if (migratedExternalVisibility.changed) { - this.loadNeedsSave = true - } - const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) - if (migratedTerminalScrollback.needsSave) { - this.loadNeedsSave = true - } - if ( - parsed.settings && - typeof parsed.settings === 'object' && - Object.hasOwn(parsed.settings, 'enableGitHubAttribution') - ) { - this.loadNeedsSave = true - } - const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( - parsed.settings - ) - if (migratedTerminalTuiScrollSensitivity.needsSave) { - this.loadNeedsSave = true - } - const rawSourceControlAi = parsed.settings?.sourceControlAi - const rawSourceControlAiMissing = rawSourceControlAi === undefined - const rawSourceControlAiActionsMissing = - rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined - if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { - this.loadNeedsSave = true - } - const legacyCommitMessageAi = parsed.settings?.commitMessageAi - const migratedSourceControlAi = rawSourceControlAiMissing - ? sourceControlAiSettingsFromLegacy( - legacyCommitMessageAi ?? defaults.settings.commitMessageAi - ) - : mergeLegacyCommitMessageAiIntoSourceControlAi( - parsed.settings?.sourceControlAi, - legacyCommitMessageAi - ) - // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. - 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 builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. - const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers - ? (parsed.settings?.floatingTerminalEnabled ?? true) - : true - // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). - const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) - const osc52ClipboardNoticePending = - osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || - parsed.ui?.osc52ClipboardDefaultOnNoticePending === true - if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { - this.loadNeedsSave = true - } - const floatingTerminalCwdMigrated = - parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true - // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. - const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated - ? !parsed.settings?.floatingTerminalCwd - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - : parsed.settings?.floatingTerminalCwd === undefined - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( - parsed.settings?.floatingTerminalTrustedCwds, - homeDir - ) - const migratedFloatingTerminalTrustedCwds = [ - ...normalizedFloatingTerminalTrustedCwds.trustedCwds - ] - const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd - const shouldTrustLegacyFloatingTerminalCwd = - !floatingTerminalCwdMigrated && - typeof rawLegacyFloatingTerminalCwd === 'string' && - rawLegacyFloatingTerminalCwd.trim().length > 0 && - rawLegacyFloatingTerminalCwd.trim() !== '~' - if (!floatingTerminalCwdMigrated) { - this.loadNeedsSave = true - } - if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { - const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( - rawLegacyFloatingTerminalCwd, - homeDir - ) - if ( - canonicalLegacyCwd && - !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd) - ) { - // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. - migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) - normalizedFloatingTerminalTrustedCwds.changed = true - } - } - if (normalizedFloatingTerminalTrustedCwds.changed) { - this.loadNeedsSave = true - } - const experimentalActivityDefaultedOffForAllUsers = - parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true - // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. - const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers - ? (parsed.settings?.experimentalActivity ?? false) - : false - const autoRenameBranchFromWorkDefaultedOn = - parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true - // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. - const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( - parsed.settings - ) - const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) - if ( - parsed.settings?.terminalCursorStyle !== - migratedTerminalCursorStyle.terminalCursorStyle || - parsed.settings?.terminalCursorStyleDefaultedToBlock !== true - ) { - this.loadNeedsSave = true - } - const migratedTerminalLineHeight = normalizeTerminalLineHeight( - parsed.settings?.terminalLineHeight - ) - const terminalRightClickToPasteDefaultedForPlatform = - parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true - if (!terminalRightClickToPasteDefaultedForPlatform) { - this.loadNeedsSave = true - } - if ( - parsed.settings?.terminalLineHeight !== undefined && - parsed.settings.terminalLineHeight !== migratedTerminalLineHeight - ) { - this.loadNeedsSave = true - } - const rawTaskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: parsed.settings?.visibleTaskProviders, - defaultTaskSource: parsed.settings?.defaultTaskSource - }) - const visibleTaskProvidersDefaultedForJira = - parsed.settings?.visibleTaskProvidersDefaultedForJira === true - const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira - ? rawTaskProviderSettings.visibleTaskProviders - : rawTaskProviderSettings.visibleTaskProviders.includes('jira') - ? rawTaskProviderSettings.visibleTaskProviders - : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] - const taskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: migratedVisibleTaskProviders, - defaultTaskSource: rawTaskProviderSettings.defaultTaskSource - }) - const primarySelectionDefaultedForLinux = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true - const primarySelectionDefaultedForTerminalDefaults = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true - const primarySelectionPlatformDefaultEnabled = - defaults.settings.primarySelectionMiddleClickPaste === true - const primarySelectionAlreadyDefaultedForPlatform = - primarySelectionDefaultedForTerminalDefaults || - (process.platform === 'linux' && primarySelectionDefaultedForLinux) - const migratePrimarySelectionPlatformDefault = - primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform - const stampPrimarySelectionTerminalDefaults = - primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults - if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { - this.loadNeedsSave = true - } - if (!visibleTaskProvidersDefaultedForJira) { - this.loadNeedsSave = true - } - const claudeAgentTeamsDefaultDisabledMigrated = - parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true - if (!claudeAgentTeamsDefaultDisabledMigrated) { - this.loadNeedsSave = true - } - const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( - parsed.settings?.disabledTuiAgents - ) - const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) - if ( - parsed.settings?.agentYoloDefaultsMigrated !== true || - hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || - hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) - ) { - this.loadNeedsSave = true - } - if ( - !claudeAgentTeamsDefaultDisabledMigrated && - !migratedDisabledTuiAgents.includes('claude-agent-teams') - ) { - migratedDisabledTuiAgents.push('claude-agent-teams') - } - const migratedWindowsRuntimeDefault = - parsed.settings?.localWindowsRuntimeDefault === undefined - ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime - : parsed.settings.localWindowsRuntimeDefault - if ( - parsed.settings?.localWindowsRuntimeDefault === undefined && - migratedWindowsRuntimeDefault.kind === 'wsl' - ) { - this.loadNeedsSave = true - } - // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. - const localAccountRuntimeAlreadyMigrated = - parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true - const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = - localAccountRuntimeAlreadyMigrated - ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) - : parsed.settings?.localAccountRuntime === 'wsl' - ? 'wsl' - : 'auto' - if (!localAccountRuntimeAlreadyMigrated) { - this.loadNeedsSave = true - } - if (!autoRenameBranchFromWorkDefaultedOn) { - this.loadNeedsSave = true - } - const normalizedOnboarding = normalizeLoadedOnboardingState( - parsed.onboarding, - defaults.onboarding - ) - if (!parsed.onboarding) { - this.loadNeedsSave = true - } - const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) - const loadedCompactWorktreeCards = - parsed.settings?.compactWorktreeCards ?? - parsed.settings?.experimentalCompactWorktreeCards ?? - defaults.settings.compactWorktreeCards - const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( - parsed.settings?.mobilePairingCustomAddress - ) - const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses - const mobilePairingCustomAddresses = mobilePairingCustomAddress - ? addMobilePairingCustomAddress( - normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), - mobilePairingCustomAddress - ) - : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) - if ( - parsed.settings?.mobilePairingCustomAddress !== undefined && - parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress - ) { - this.loadNeedsSave = true - } - const customAddressesMatch = - Array.isArray(rawMobilePairingCustomAddresses) && - rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && - rawMobilePairingCustomAddresses.every( - (address, index) => address === mobilePairingCustomAddresses[index] - ) - if ( - (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && - !customAddressesMatch - ) { - this.loadNeedsSave = true - } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } - result = { - ...defaults, - ...parsed, - featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( - parsed.featureInteractionTelemetryBuckets - ), - projectGroups: normalizedProjectGroups, - repos: migratedExternalVisibility.repos, - folderWorkspaces: normalizeFolderWorkspaces( - parsed.folderWorkspaces, - normalizedProjectGroups - ), - folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( - parsed.folderWorkspaceDiffComments - ), - worktreeLineageById: parsed.worktreeLineageById ?? {}, - mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( - parsed.mobileClientTabSelectionsByDeviceId - ), - workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( - parsed.workspaceLineageByChildKey - ), - settings: { - ...defaults.settings, - // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripRetiredGlobalSettings(parsed.settings), - worktreeVisibilityDefaults: migratedExternalVisibility.defaults, - prBotAuthorOverrides: normalizePRBotAuthorOverrides( - parsed.settings?.prBotAuthorOverrides - ), - // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. - experimentalPet: - parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, - // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. - primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault - ? true - : (parsed.settings?.primarySelectionMiddleClickPaste ?? - defaults.settings.primarySelectionMiddleClickPaste), - primarySelectionMiddleClickPasteDefaultedForLinux: - primarySelectionDefaultedForLinux || - (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: - primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, - ...migratedAutoRenameBranchFromWork, - ...migratedTerminalCursorStyle, - terminalLineHeight: migratedTerminalLineHeight, - // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. - terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform - ? (parsed.settings?.terminalRightClickToPaste ?? - defaults.settings.terminalRightClickToPaste) - : parsed.settings?.terminalRightClickToPaste === false - ? false - : defaults.settings.terminalRightClickToPaste, - terminalRightClickToPasteDefaultedForPlatform: true, - ...migratedTerminalTuiScrollSensitivity.settings, - experimentalActivity: migratedExperimentalActivity, - experimentalActivityDefaultedOffForAllUsers: true, - // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. - compactWorktreeCards: loadedCompactWorktreeCards, - experimentalCompactWorktreeCards: undefined, - terminalMacOptionAsAlt: migratedOptionAsAlt, - terminalMacOptionAsAltMigrated: true, - localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, - localAccountRuntime: migratedLocalAccountRuntime, - localAccountRuntimeDefaultedToAutoForAllUsers: true, - ...migratedOsc52Clipboard, - floatingTerminalEnabled: migratedFloatingTerminalEnabled, - floatingTerminalDefaultedForAllUsers: true, - floatingTerminalCwd: migratedFloatingTerminalCwd, - floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, - floatingTerminalCwdMigratedToAppWorkspace: true, - terminalScrollbackRows: migratedTerminalScrollback.rows, - terminalQuickCommands: normalizeTerminalQuickCommands( - parsed.settings?.terminalQuickCommands - ), - terminalCustomThemes: normalizeTerminalCustomThemes( - parsed.settings?.terminalCustomThemes - ), - appIcon: normalizeAppIconId(parsed.settings?.appIcon), - mobilePairingCustomAddress, - mobilePairingCustomAddresses, - // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. - minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, - // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. - showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, - uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), - defaultTaskSource: taskProviderSettings.defaultTaskSource, - visibleTaskProviders: taskProviderSettings.visibleTaskProviders, - visibleTaskProvidersDefaultedForJira: true, - terminalShortcutPolicy: normalizeTerminalShortcutPolicy( - parsed.settings?.terminalShortcutPolicy - ), - disabledTuiAgents: migratedDisabledTuiAgents, - ...migratedAgentYoloDefaults, - claudeAgentTeamsDefaultDisabledMigrated: true, - openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { - seedDefaults: true - }), - notifications: normalizeNotificationSettings(parsed.settings?.notifications), - sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, - // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. - commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( - migratedSourceControlAi, - parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi - ), - voice: { - ...getDefaultVoiceSettings(), - ...parsed.settings?.voice - } - }, - // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. - ui: (() => { - const rawSort = parsed.ui?.sortBy - const sort = normalizeSortBy(rawSort) - const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' - const rightSidebarOpen = - typeof parsed.ui?.rightSidebarOpen === 'boolean' - ? parsed.ui.rightSidebarOpen - : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' - ? parsed.settings.rightSidebarOpenByDefault - : defaults.ui.rightSidebarOpen - if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { - this.loadNeedsSave = true - } - const workspaceStatusesDefaultOrderMigrated = - parsed.ui?._workspaceStatusesDefaultOrderMigrated === true - // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. - const workspaceStatusesReorderedDefaultRepaired = - parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true - // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. - 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. - const workspaceStatusesDefaultVisualsMigrated = - parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true - const workspaceStatuses = normalizePersistedWorkspaceStatuses( - parsed.ui?.workspaceStatuses, - { - migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, - repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, - migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated - } - ) - if ( - !workspaceStatusesDefaultOrderMigrated || - !workspaceStatusesReorderedDefaultRepaired || - !workspaceStatusesDefaultWorkflowMigrated || - !workspaceStatusesDefaultVisualsMigrated - ) { - this.loadNeedsSave = true - } - const rawCardProps = parsed.ui?.worktreeCardProperties - const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true - const expandedCardPropsMigrated = - parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true - const jiraIssueCardPropDefaulted = - parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === 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 needsLegacyDefaultedCompactMigration = - loadedCompactWorktreeCards && - parsed.ui?._worktreeCardModeDefaulted === true && - isDefaultedCompactWorktreeCardProperties(rawCardProps) - const migratedCardProps = (() => { - if (!Array.isArray(rawCardProps)) { - return undefined - } - if (needsLegacyDefaultedCompactMigration) { - return getWorktreeCardModeProperties('Compact') - } - const candidate = needsInlineAgentsMigration - ? [...rawCardProps, 'inline-agents' as const] - : rawCardProps - const expandedCandidate = (() => { - if (expandedCardPropsMigrated) { - return candidate - } - const next = [...candidate] - // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. - if (candidate.includes('issue') && !candidate.includes('linear-issue')) { - next.push('linear-issue' as const) - } - if (!candidate.includes('ports')) { - next.push('ports' as const) - } - return next - })() - // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. - const jiraCandidate = - jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') - ? expandedCandidate - : [...expandedCandidate, 'jira-issue' as const] - const normalized = normalizeWorktreeCardProperties(jiraCandidate) - const changed = - normalized.length !== rawCardProps.length || - normalized.some((property, index) => property !== rawCardProps[index]) - return changed ? normalized : undefined - })() - if ( - migratedCardProps !== undefined || - !inlineAgentsMigrated || - !expandedCardPropsMigrated || - !jiraIssueCardPropDefaulted - ) { - this.loadNeedsSave = true - } - const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( - parsed.ui?.setupGuideSidebarDismissed, - normalizedOnboarding - ) - if ( - parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && - (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) - ) { - this.loadNeedsSave = true - } - // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. - const usagePercentageDisplayChangeNoticeDismissed = - resolveUsagePercentageDisplayChangeNoticeDismissed({ - rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, - rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, - isExistingProfile: isExistingPersistedProfile({ - repoCount: parsed.repos?.length ?? 0, - onboardingClosedAt: normalizedOnboarding.closedAt, - ui: parsed.ui - }) - }) - if ( - parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== - usagePercentageDisplayChangeNoticeDismissed - ) { - this.loadNeedsSave = true - } - return { - ...defaults.ui, - // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. - worktreeCardProperties: getWorktreeCardModeProperties( - loadedCompactWorktreeCards ? 'Compact' : 'Default' - ), - ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), - // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. - rightSidebarOpen, - rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), - setupGuideSidebarDismissed, - usagePercentageDisplayChangeNoticeDismissed, - setupGuideBrowserMilestoneMigrated: - typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' - ? parsed.ui.setupGuideBrowserMilestoneMigrated - : false, - setupGuideBrowserMilestoneLegacyComplete: - parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, - // Why persist rather than notify inline: the flip lands during load, before any - // window exists, and it must survive a crash before the user ever sees the notice. - osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, - sortBy: migrate ? ('smart' as const) : sort, - showDotfilesByWorktree: normalizeShowDotfilesByWorktree( - parsed.ui?.showDotfilesByWorktree - ), - workspaceStatuses, - _workspaceStatusesDefaultOrderMigrated: true, - _workspaceStatusesReorderedDefaultRepaired: true, - _workspaceStatusesDefaultWorkflowMigrated: true, - _workspaceStatusesDefaultVisualsMigrated: true, - _sortBySmartMigrated: true, - ...(migratedCardProps !== undefined - ? { worktreeCardProperties: migratedCardProps } - : {}), - // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. - _inlineAgentsDefaultedForExperiment: true, - _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true, - _jiraIssueWorktreeCardPropertyDefaulted: true - } - })(), - // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: (() => { - if (parsed.workspaceSession === undefined) { - return defaults.workspaceSession - } - const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) - if (!result.ok) { - console.error( - '[persistence] Corrupt workspace session, using defaults:', - result.error - ) - return defaults.workspaceSession - } - if (result.droppedCount > 0) { - console.warn( - '[persistence] Salvaged workspace session; dropped corrupt entries:', - workspaceSessionSalvageLogDetails(result) - ) - // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return { ...defaults.workspaceSession, ...result.value } - })(), - // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: (() => { - const { partitions, repaired } = parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ) - if (repaired) { - // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return partitions - })(), - sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), - deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) - ? parsed.deletedSshConfigAliases.filter( - (alias): alias is string => typeof alias === 'string' - ) - : [], - retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByRepo - ), - retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByNamespace - ), - sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) - .map(normalizeSshRemotePtyLease) - .filter((lease): lease is SshRemotePtyLease => lease !== null), - sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), - migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( - parsed.migrationUnsupportedPtyEntries - ), - legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( - parsed.legacyPaneKeyAliasEntries - ), - automations: Array.isArray(parsed.automations) ? parsed.automations : [], - automationRuns: (() => { - if (!Array.isArray(parsed.automationRuns)) { - return [] - } - const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) - // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. - if (runs.length !== parsed.automationRuns.length) { - this.loadNeedsSave = true - } - return runs - })(), - onboarding: normalizedOnboarding - } - } - } catch (err) { - console.error('[persistence] Failed to load primary state, trying backups:', err) - } - - // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - 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()) - } - - const workspaceSession = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) - ) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - if (migratedScrollback.changed) { - this.loadNeedsSave = true - } - - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) - const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) - if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { - this.loadNeedsSave = true - } - - const automationContextMigration = backfillLegacyAutomationContexts({ - ...result, - repos, - ...projectHostSetupCompatibility - }) - if (automationContextMigration.changed) { - this.loadNeedsSave = true - } - result = { - ...result, - automations: automationContextMigration.state.automations, - automationRuns: automationContextMigration.state.automationRuns - } - - const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ - ...result, - repos, - ...projectHostSetupCompatibility, - workspaceSession: migratedScrollback.session - }) - if (folderScopeConnectionMigration.changed) { - this.loadNeedsSave = true - } - result = folderScopeConnectionMigration.state - - if (normalizeWorktreeLinkedItemMetadata(result)) { - this.loadNeedsSave = true - } - - if (gcStaleWorktreeMeta(result) > 0) { - this.loadNeedsSave = true - } - - const migrated = this.migrateTabSwitchKeybindings( - this.migrateTelemetry(result, fileExistedOnLoad), - fileExistedOnLoad - ) - - // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. - const legacyCache = migrated.githubCache - const hasLegacyCache = - Object.keys(legacyCache?.pr ?? {}).length > 0 || - Object.keys(legacyCache?.issue ?? {}).length > 0 - if (hasLegacyCache) { - this.loadNeedsSave = true - // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. - this.githubCacheDirty = true - } else { - migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache - } - - logPersistenceStartupMilestone('persistence-load-done', { - repos: migrated.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) - }) - return migrated - } - - // One-shot telemetry cohort migration: seeds existedBeforeTelemetryRelease, optedIn, and installId (no-op once set). - // One-shot tab-switch cohort freeze: fileExistedOnLoad tells existing vs fresh only on the first launch, so persist now. - private migrateTabSwitchKeybindings( - state: PersistedState, - fileExistedOnLoad: boolean - ): PersistedState { - const existing = state.settings?.tabSwitchKeybindingSeed - if (existing === 'pending' || existing === 'done') { - return state - } - // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. - this.loadNeedsSave = true - return { - ...state, - settings: { - ...state.settings, - // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. - tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' - } - } - } - - private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { - const existing = state.settings?.telemetry - // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. - 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: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. - const resolvedExistedBefore = - typeof existing?.existedBeforeTelemetryRelease === 'boolean' - ? existing.existedBeforeTelemetryRelease - : fileExistedOnLoad - return { - ...state, - settings: { - ...state.settings, - telemetry: { - ...existing, - existedBeforeTelemetryRelease: resolvedExistedBefore, - // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when 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() - } - } - } - } - - // Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s. - private static SAVE_DEBOUNCE_MS = 1_000 - private static SAVE_MAX_WAIT_MS = 5_000 - - private scheduleSave(): void { - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (this.quitFlushStarted) { - return - } - this.writeGeneration += 1 - const now = Date.now() - this.firstPendingSaveAt ??= now - if (this.writeTimer) { - clearTimeout(this.writeTimer) - } - const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) - const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) - this.writeTimer = setTimeout(() => { - this.writeTimer = null - this.firstPendingSaveAt = null - void this.enqueueWrite() - }, delay) - } - - private enqueueWrite(): Promise { - const previousWrite = Promise.all([ - this.pendingWrite ?? this.staleTempCleanup, - this.pendingSnapshotFileWork ?? Promise.resolve() - ]).then(() => {}) - const write = previousWrite.then(() => this.writeToDiskAsync()) - const trackedWrite = write - .catch((err) => { - console.error('[persistence] Failed to write state:', err) - }) - .finally(() => { - if (this.pendingWrite === trackedWrite) { - this.pendingWrite = null - } - }) - this.pendingWrite = trackedWrite - return write - } - - /** Wait for any in-flight async disk write to complete. Used in tests. */ - async waitForPendingWrite(): Promise { - await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) - } - - // Why githubCache is omitted: memory-only this session (see getGithubCacheFile), so refreshes never touch the durable file. - private getDurableState(): Omit { - const { githubCache: _memoryOnly, ...durable } = this.state - return durable - } - - // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. - private buildStateToSave(): { - payload: string - stateHash: string - protectedSecretUpdates: ProtectedSecretRetentionUpdate[] - } { - // Why sentinels (not a blob/key string match): the substitution must be - // position-exact. A plain search for the ciphertext — or even for a - // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an - // agentDefaultEnv var named after a secret field, or a value equal to a - // ciphertext), which would substitute the wrong site and let two DISTINCT - // states normalize equal → a silently dropped write (data loss), reachable - // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot - // random UUID can't occur anywhere else in the serialized state (the user - // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] - const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] - let protectedStorageDegraded = false - const encryptToSentinel = (slot: string, plaintext: string): string => { - const encrypted = this.protectedSecrets.encrypt(slot, plaintext) - if (encrypted.retentionUpdate) { - protectedSecretUpdates.push(encrypted.retentionUpdate) - } - protectedStorageDegraded ||= encrypted.degraded - const { blob, hashValue = plaintext } = encrypted - // Values already identical in payload and hash need no sentinel substitution. - if (blob === plaintext && hashValue === plaintext) { - return blob - } - const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, hashValue }) - return sentinel - } - const encryptOptionalToSentinel = ( - slot: string, - plaintext: string | null | undefined - ): string | null => { - const encrypted = encryptToSentinel(slot, plaintext ?? '') - return encrypted || null - } - // Why: clone before encrypting secrets so in-memory this.state stays plaintext. - const stateToSave = { - ...this.getDurableState(), - // Why both keys unconditionally: the explicit keys always win over the spread, and - // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. - // The strip builds a new array here only; this.state records keep their notes in memory. - folderWorkspaces: (this.state.folderWorkspaces ?? []).map( - ({ diffComments: _relocated, ...rest }) => rest - ), - folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces), - sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ - ...record, - ownerLease: encryptToSentinel( - sshPtyOwnerLeaseSecretSlot(record.targetId), - record.ownerLease - ) - })), - settings: { - ...stripRetiredGlobalSettings(this.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.state.settings.opencodeSessionCookie - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.state.settings.httpProxyUrl ?? '' - ) - }, - ui: { - ...this.state.ui, - browserKagiSessionLink: encryptOptionalToSentinel( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - this.state.ui.browserKagiSessionLink - ) - } - } - // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. - // One full-state stringify; secret slots currently hold sentinels. - const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') - return { payload, stateHash, protectedSecretUpdates } - } - - // Why: async writes avoid blocking the main Electron thread on every debounced save. - private async writeToDiskAsync(): Promise { - if (this.writesFrozen) { - return - } - const gen = this.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === this.lastWrittenStateHash) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - await handle.writeFile(payload, 'utf-8') - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (this.writeGeneration !== gen) { - return - } - this.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { - throw error - } - } finally { - if (this.inFlightAsyncTmpFile === tmpFile) { - this.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && this.writeGeneration === gen) { - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - } else if (renamed) { - this.lastWrittenStateHash = null - } - if (renamed) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (this.writeGeneration !== gen) { - return - } - await this.rotateBackupsAsync(dataFile) - } - - // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. - private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { - if (this.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if (!opts.force && stateHash === this.lastWrittenStateHash) { - return - } - const dataFile = this.dataFile - 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: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - this.lastDurableWriteGeneration = Math.max( - this.lastDurableWriteGeneration, - this.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { - this.rotateBackupsSync(dataFile) - } - } - - flushOrThrow(): void { - if (this.quitFlushStarted) { - throw new Error('Cannot synchronously flush after final persistence has started') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const asyncWriteWasInFlight = this.pendingWrite !== null - // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this.writeGeneration++ - if (this.inFlightAsyncTmpFile) { - try { - unlinkSync(this.inFlightAsyncTmpFile) - this.inFlightAsyncTmpFile = null - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - void this.enqueueWrite().catch(() => {}) - throw error - } - } - } - // Why: later async flushes must remain serialized behind the invalidated writer. - this.writeToDiskSync({ - force: asyncWriteWasInFlight, - skipBackupRotation: this.backupRotationInFlight - }) - } - - flushActiveViewPreferenceOrThrow(): void { - this.activeViewPreference.flushOrThrow() - } - - getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { - return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) - } - - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } - const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this.state.codexResetCreditAttemptLedger - ? structuredClone(this.state.codexResetCreditAttemptLedger) - : undefined - this.state.codexResetCreditAttemptLedger = next - try { - this.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - this.state.codexResetCreditAttemptLedger = previous - throw error - } - } - - // ── Repos ────────────────────────────────────────────────────────── - - getProfileStorageDirectory(): string { - return dirname(this.dataFile) - } - - getRepos(): Repo[] { - return this.state.repos.map((repo) => this.hydrateRepo(repo)) - } - - getProjects(): Project[] { - return [...this.state.projects] - } - - updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { - const project = this.state.projects.find((entry) => entry.id === id) - if (!project) { - return null - } - if ('localWindowsRuntimePreference' in updates) { - if (updates.localWindowsRuntimePreference === undefined) { - delete project.localWindowsRuntimePreference - } else { - project.localWindowsRuntimePreference = normalizeProjectRuntimePreference( - updates.localWindowsRuntimePreference - ) - } - } - project.updatedAt = Date.now() - this.scheduleSave() - return { ...project } - } - - getProjectHostSetups(): ProjectHostSetup[] { - return [...this.state.projectHostSetups] - } - - createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { - const project = this.state.projects.find((entry) => entry.id === args.projectId) - if (!project) { - return null - } - const hostId = normalizeExecutionHostId(args.hostId) - if (!hostId) { - throw new Error(`Invalid host ID: ${args.hostId}`) - } - const duplicateSetup = this.state.projectHostSetups.find( - (entry) => entry.projectId === project.id && entry.hostId === hostId - ) - if (duplicateSetup) { - throw new Error(`Project host setup already exists: ${duplicateSetup.id}`) - } - const now = Date.now() - const existingIds = new Set(this.state.projectHostSetups.map((entry) => entry.id)) - const setup: ProjectHostSetup = { - id: makeProjectHostSetupId(project.id, hostId, existingIds, args.setupId), - projectId: project.id, - hostId, - repoId: '', - path: args.path?.trim() ?? '', - displayName: args.displayName?.trim() || project.displayName, - ...(args.kind ? { kind: args.kind } : {}), - ...(args.worktreeBasePath?.trim() ? { worktreeBasePath: args.worktreeBasePath.trim() } : {}), - ...(args.gitUsername?.trim() ? { gitUsername: args.gitUsername.trim() } : {}), - setupState: args.setupState ?? 'not-set-up', - setupMethod: args.setupMethod ?? 'provisioned', - createdAt: now, - updatedAt: now - } - // Why: persist independently so future repo projection sync doesn't erase this non-repo-backed setup. - this.state.projectHostSetups.push(setup) - this.scheduleSave() - return { project, setup } - } - - updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { - const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) - if (!setup) { - return null - } - const project = this.state.projects.find((entry) => entry.id === setup.projectId) - if (!project) { - return null - } - const repo = setup.repoId - ? this.state.repos.find((entry) => entry.id === setup.repoId) - : undefined - if (repo) { - const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates) - const updatedProject = updated - ? this.state.projects.find((entry) => entry.id === updated.setup.projectId) - : undefined - return updated && updatedProject - ? { project: updatedProject, setup: updated.setup, repo: updated.repo } - : null - } - const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates) - return { project, setup: updatedSetup } - } - - deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { - const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) - if (!setup) { - return null - } - const project = this.state.projects.find((entry) => entry.id === setup.projectId) - if (!project) { - return null - } - // Why: the same repo id can exist on multiple execution hosts, so match this setup's own host - // row and never fall back to a sibling host's row — a stale repoId/hostId would delete that host's - // registration. With no exact match the setup is stale, and the path below drops just the setup. - const repo = setup.repoId - ? this.state.repos.find( - (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId - ) - : undefined - if (repo) { - this.removeProjectForHost(repo.id, setup.hostId) - return { project, setup, repo: this.hydrateRepo(repo) } - } - this.state.projectHostSetups = this.state.projectHostSetups.filter( - (entry) => entry.id !== setup.id - ) - this.scheduleSave() - return { project, setup } - } - - /** O(1) repo count; unlike `getRepos()` this skips per-repo hydration. */ - 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 - } - - /** - * Record a background-resolved git username; kept out of updateRepo's whitelist so the renderer can't write it directly. - * @returns true when the hydrated value changed. - */ - setResolvedRepoGitUsername(id: string, username: string): boolean { - const repo = this.state.repos.find((r) => r.id === id) - if (!repo) { - return false - } - const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '' - this.gitUsernameCache.set(repo.path, username) - if (previous === username) { - return false - } - if (username) { - // Why: persist so the next launch hydrates repos with the right branch prefix before enrichment re-runs. - repo.gitUsername = username - } else { - delete repo.gitUsername - } - this.scheduleSave() - return true - } - - getProjectGroups(): ProjectGroup[] { - return [...(this.state.projectGroups ?? [])].sort( - (left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name) - ) - } - - createProjectGroup(input: { - name: string - parentPath?: string | null - connectionId?: string | null - parentGroupId?: string | null - createdFrom: ProjectGroup['createdFrom'] - }): ProjectGroup { - let maxOrder = -1 - // Why: persisted group lists can be large enough to exceed spread limits. - for (const existingGroup of this.state.projectGroups ?? []) { - maxOrder = Math.max(maxOrder, existingGroup.tabOrder) - } - const group = createProjectGroup({ - ...input, - tabOrder: maxOrder + 1 - }) - this.state.projectGroups = [...(this.state.projectGroups ?? []), group] - this.scheduleSave() - return group - } - - updateProjectGroup( - groupId: string, - updates: Partial> - ): ProjectGroup | null { - const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId) - if (!group) { - return null - } - if (updates.name !== undefined) { - group.name = normalizeProjectGroupName(updates.name, group.name) - } - if (updates.isCollapsed !== undefined) { - group.isCollapsed = updates.isCollapsed - } - if (updates.tabOrder !== undefined && Number.isFinite(updates.tabOrder)) { - group.tabOrder = updates.tabOrder - } - if (updates.color !== undefined) { - group.color = typeof updates.color === 'string' ? updates.color : null - } - group.updatedAt = Date.now() - this.scheduleSave() - return group - } - - deleteProjectGroup(groupId: string): boolean { - const before = this.state.projectGroups?.length ?? 0 - const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId) - this.state.projectGroups = (this.state.projectGroups ?? []).filter( - (group) => !deletedGroupIds.has(group.id) - ) - if ((this.state.projectGroups?.length ?? 0) === before) { - return false - } - // Why: groups are sidebar organization only, so deleting one ungroups its repos rather than deleting them. - this.state.repos = this.state.repos.map((repo) => - repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) - ? { ...repo, projectGroupId: null } - : repo - ) - const removedFolderWorkspaceKeys = new Set() - for (const workspace of this.state.folderWorkspaces ?? []) { - if (deletedGroupIds.has(workspace.projectGroupId)) { - removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id)) - this.state.workspaceSession = removeWorkspaceSessionOwner( - this.state.workspaceSession, - folderWorkspaceKey(workspace.id) - )! - this.removeWorkspaceLineageForFolderParent(workspace.id) - } - } - this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( - (workspace) => !deletedGroupIds.has(workspace.projectGroupId) - ) - this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId)) - this.scheduleSave() - return true - } - - getFolderWorkspaces(): FolderWorkspace[] { - return [...(this.state.folderWorkspaces ?? [])].sort( - (left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name) - ) - } - - getFolderWorkspace(id: string): FolderWorkspace | undefined { - return (this.state.folderWorkspaces ?? []).find((workspace) => workspace.id === id) - } - - createFolderWorkspace(input: { - projectGroupId: string - name?: string - folderPath?: string | null - linkedTask?: FolderWorkspace['linkedTask'] - linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] - connectionId?: string | null - creatorProvenance?: FolderWorkspace['creatorProvenance'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }): FolderWorkspace { - const group = (this.state.projectGroups ?? []).find( - (entry) => entry.id === input.projectGroupId - ) - const folderPath = - typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 - ? input.folderPath - : group?.parentPath - if (!group || !folderPath) { - throw new Error('Folder-backed project group not found.') - } - const now = Date.now() - const linkedTask = normalizeWorkspaceLinkedItem(input.linkedTask) - const sourceContext = normalizeStoredTaskSourceContext(input.linkedTaskSourceContext) - const workspace: FolderWorkspace = { - id: randomUUID(), - projectGroupId: group.id, - name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`), - folderPath, - connectionId: input.connectionId ?? group.connectionId ?? null, - ...(input.creatorProvenance ? { creatorProvenance: input.creatorProvenance } : {}), - linkedTask, - linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) - ? sourceContext - : null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: now, - ...(input.createdWithAgent ? { createdWithAgent: input.createdWithAgent } : {}), - ...(input.pendingFirstAgentMessageRename === true && input.createdWithAgent - ? { pendingFirstAgentMessageRename: true } - : {}), - lastActivityAt: 0, - createdAt: now, - updatedAt: now - } - this.state.folderWorkspaces = [workspace, ...(this.state.folderWorkspaces ?? [])] - this.scheduleSave() - return workspace - } - - updateFolderWorkspace( - id: string, - updates: Partial< - Pick< - FolderWorkspace, - | 'name' - | 'folderPath' - | 'linkedTask' - | 'linkedTaskSourceContext' - | 'comment' - | 'isArchived' - | 'isUnread' - | 'isPinned' - | 'sortOrder' - | 'manualOrder' - | 'workspaceStatus' - | 'createdWithAgent' - | 'pendingFirstAgentMessageRename' - | 'firstAgentMessageRenameError' - | 'lastActivityAt' - | 'diffComments' - > - > - ): FolderWorkspace | null { - const workspace = this.getFolderWorkspace(id) - if (!workspace) { - return null - } - if (updates.name !== undefined) { - workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name) - } - if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { - workspace.folderPath = updates.folderPath - } - if (updates.linkedTask !== undefined) { - workspace.linkedTask = normalizeWorkspaceLinkedItem(updates.linkedTask) - if ( - workspace.linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch( - workspace.linkedTask, - workspace.linkedTaskSourceContext - ) - ) { - workspace.linkedTaskSourceContext = null - } - } - if (updates.linkedTaskSourceContext !== undefined) { - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updates.linkedTaskSourceContext - ) - workspace.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - workspace.linkedTask, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null - } - if (updates.comment !== undefined) { - workspace.comment = updates.comment - } - if (updates.isArchived !== undefined) { - workspace.isArchived = updates.isArchived - } - if (updates.isUnread !== undefined) { - workspace.isUnread = updates.isUnread - } - if (updates.isPinned !== undefined) { - workspace.isPinned = updates.isPinned - } - if (updates.sortOrder !== undefined && Number.isFinite(updates.sortOrder)) { - workspace.sortOrder = updates.sortOrder - } - if (updates.manualOrder !== undefined) { - if (Number.isFinite(updates.manualOrder)) { - workspace.manualOrder = updates.manualOrder - } else { - delete workspace.manualOrder - } - } - if (updates.workspaceStatus !== undefined) { - workspace.workspaceStatus = updates.workspaceStatus - } - if (updates.createdWithAgent !== undefined) { - workspace.createdWithAgent = updates.createdWithAgent - } - if (updates.pendingFirstAgentMessageRename !== undefined) { - workspace.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename - } - if (updates.firstAgentMessageRenameError !== undefined) { - workspace.firstAgentMessageRenameError = updates.firstAgentMessageRenameError - } - if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) { - workspace.lastActivityAt = updates.lastActivityAt - } - if (updates.diffComments !== undefined) { - workspace.diffComments = updates.diffComments - } - workspace.updatedAt = Date.now() - this.scheduleSave() - return workspace - } - - removeFolderWorkspace(id: string): boolean { - const before = this.state.folderWorkspaces?.length ?? 0 - this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( - (workspace) => workspace.id !== id - ) - if ((this.state.folderWorkspaces?.length ?? 0) === before) { - return false - } - this.state.workspaceSession = removeWorkspaceSessionOwner( - this.state.workspaceSession, - folderWorkspaceKey(id) - )! - this.removeWorkspaceLineageForFolderParent(id) - this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id)) - this.scheduleSave() - return true - } - - moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { - const repo = this.state.repos.find((entry) => entry.id === repoId) - if (!repo) { - return null - } - const normalizedGroupId = - groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId) - ? groupId - : null - const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId) - repo.projectGroupId = normalizedGroupId - repo.projectGroupOrder = - typeof order === 'number' && Number.isFinite(order) - ? order - : getNextProjectGroupOrder(siblingRepos, normalizedGroupId) - this.scheduleSave() - return this.hydrateRepo(repo) - } - - addRepo(repo: Repo): void { - this.state.repos.push(repo) - this.syncProjectHostSetupCompatibilityState() - this.scheduleSave() - } - - // Why: return false on a stale permutation (concurrent add/remove) so the caller resyncs instead of persisting an order that drops/duplicates ids. - reorderRepos(orderedIds: string[]): boolean { - const current = this.state.repos - if (orderedIds.length !== current.length) { - return false - } - const seen = new Set() - for (const id of orderedIds) { - if (typeof id !== 'string' || seen.has(id)) { - return false - } - seen.add(id) - } - const byId = new Map() - 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.syncProjectHostSetupCompatibilityState() - this.scheduleSave() - return true - } - - // Why: repo ids are unique only within an execution host; drags persist one permutation per host when local and SSH repos coexist. - reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { - const current = this.state.repos - const hostRepos = current.filter((repo) => getRepoExecutionHostId(repo) === hostId) - if (orderedIds.length !== hostRepos.length) { - return false - } - const byId = new Map(hostRepos.map((repo) => [repo.id, repo])) - if (byId.size !== hostRepos.length) { - return false - } - const seen = new Set() - const reorderedHostRepos: Repo[] = [] - for (const id of orderedIds) { - const repo = typeof id === 'string' && !seen.has(id) ? byId.get(id) : undefined - if (!repo) { - return false - } - seen.add(id) - reorderedHostRepos.push(repo) - } - let nextHostIndex = 0 - this.state.repos = current.map((repo) => - getRepoExecutionHostId(repo) === hostId ? reorderedHostRepos[nextHostIndex++] : repo - ) - this.syncProjectHostSetupCompatibilityState() - this.scheduleSave() - return true - } - - removeProject(id: string): void { - this.state.repos = this.state.repos.filter((r) => r.id !== id) - this.syncProjectHostSetupCompatibilityState() - // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. - delete this.state.sparsePresetsByRepo[id] - // Same convention: retirements are repo-id-keyed, so they would otherwise orphan forever. - delete this.state.retiredWorktreeNamesByRepo?.[id] - this.pruneWorktreeStateForRepo(id, null) - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - this.scheduleSave() - } - - // Why: the same repo id can exist on multiple execution hosts; remove only this host's row and metadata, never another host's. - removeProjectForHost(id: string, hostId: ExecutionHostId): void { - this.state.repos = this.state.repos.filter( - (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) - ) - const idStillPresent = this.state.repos.some((r) => r.id === id) - // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the - // last host's copy is gone. - if (!idStillPresent) { - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - } - this.syncProjectHostSetupCompatibilityState() - // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). - this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) - if (!idStillPresent) { - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { - const session = this.state.workspaceSessionsByHostId?.[hostId] - if (session) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: removeRepoFromWorkspaceSession(session, id) - } - } - } - this.scheduleSave() - } - - // Prune worktree meta/lineage for a repo id; hostId null prunes all entries, else only that host's (missing meta.hostId = local). - private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { - const prefix = `${id}::` - // Why snapshot up front: the first loop deletes metas, so reading meta.hostId live later would misclassify an SSH worktree as local. - const hostMembership = new Map() - const belongsToHost = (key: string): boolean => { - if (!key.startsWith(prefix)) { - return false - } - if (hostId === null) { - return true - } - const cached = hostMembership.get(key) - if (cached !== undefined) { - return cached - } - // Why default to local: metas without hostId predate host stamping, so a host-scoped prune skips them rather than risk deleting another host's live meta. - const metaHostId = this.state.worktreeMeta[key]?.hostId ?? LOCAL_EXECUTION_HOST_ID - const result = metaHostId === hostId - hostMembership.set(key, result) - return result - } - // Why: session state (legacy blob + per-host partitions) references worktrees - // by the same `${repoId}::${path}` owner key; if it is not pruned here, a - // deleted project's worktrees stay in lastVisitedAtByWorktreeId / - // sleepingAgentSessionsByPaneKey and get re-materialized into worktreeMeta on - // the next launch, surfacing as an orphaned "unknown" workspace. - // worktreeMeta is host-classified via belongsToHost, but session partitions - // are keyed by host directly. A session owner key carries no host, and the - // same key can exist in multiple partitions (shared repo id/path across - // hosts). So for session cleanup we collect every prefix-matching owner key - // regardless of belongsToHost, and let the per-partition host gating below - // decide which partition to touch. (belongsToHost still governs - // worktreeMeta/lineage deletion. Collect before deleting worktreeMeta.) - const ownerKeysToPrune = new Set() - const collectPrefixedKeys = (keys: Iterable): void => { - for (const key of keys) { - if (key.startsWith(prefix)) { - ownerKeysToPrune.add(key) - } - } - } - collectPrefixedKeys(Object.keys(this.state.worktreeMeta)) - collectPrefixedKeys(Object.keys(this.state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) - for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { - collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) - } - - for (const key of Object.keys(this.state.worktreeMeta)) { - if (belongsToHost(key)) { - delete this.state.worktreeMeta[key] - } - } - // Why: owner keys are `${repoId}::${path}` and do not carry a host, so a - // host-scoped prune (hostId != null) must only touch that host's session: - // the legacy blob is the local host's session, and each - // workspaceSessionsByHostId partition is one non-local host. Pruning every - // partition here would wipe a surviving host's tabs, sleeping-agent state, - // and active-worktree pointer for a shared repo id/path. A full removal - // (hostId === null) still clears every host. - const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID - const pruneAllHostPartitions = hostId === null - if (pruneLegacyLocalSession) { - this.state.workspaceSession = removeWorkspaceSessionOwners( - this.state.workspaceSession, - ownerKeysToPrune - )! - } - if (this.state.workspaceSessionsByHostId) { - for (const [partitionHostId, session] of Object.entries( - this.state.workspaceSessionsByHostId - )) { - if (!pruneAllHostPartitions && partitionHostId !== hostId) { - continue - } - const pruned = removeWorkspaceSessionOwners(session, ownerKeysToPrune) - if (pruned) { - this.state.workspaceSessionsByHostId[partitionHostId] = pruned - } - } - } - for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { - if (belongsToHost(childId) || belongsToHost(lineage.parentWorktreeId)) { - delete this.state.worktreeLineageById[childId] - } - } - for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - const childScope = parseWorkspaceKey(childKey) - const parentScope = parseWorkspaceKey(lineage.parentWorkspaceKey) - if (childScope?.type === 'worktree' && belongsToHost(childScope.worktreeId)) { - delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] - continue - } - if (parentScope?.type === 'worktree' && belongsToHost(parentScope.worktreeId)) { - delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] - } - } - this.pruneMobileClientTabSelections(belongsToHost) - } - - private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { - for (const [clientNavigationId, selectionsByWorktree] of Object.entries( - this.state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - for (const worktreeId of Object.keys(selectionsByWorktree)) { - if (matchesWorktreeId(worktreeId)) { - delete selectionsByWorktree[worktreeId] - } - } - if (Object.keys(selectionsByWorktree).length === 0) { - delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] - } - } - } - - updateRepo( - id: string, - updates: Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'gitRemoteIdentity' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'executionHostId' - | 'symlinkPaths' - | 'issueSourcePreference' - | 'forkSyncMode' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - | 'projectHostSetupMethod' - > - > & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null - }, - hostId?: ExecutionHostId - ): Repo | null { - const repo = this.state.repos.find( - (candidate) => - candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) - ) - if (!repo) { - return null - } - const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates) - if ( - 'agentWorktreeVisibility' in sanitizedUpdates && - !('worktreeVisibilitySourcePreferences' in sanitizedUpdates) && - (sanitizedUpdates.agentWorktreeVisibility === 'hide' || - sanitizedUpdates.agentWorktreeVisibility === 'show') - ) { - sanitizedUpdates.worktreeVisibilitySourcePreferences = { - ...repo.worktreeVisibilitySourcePreferences, - builtIn: { - claude: sanitizedUpdates.agentWorktreeVisibility, - gsd: sanitizedUpdates.agentWorktreeVisibility - } - } - } - if ('projectGroupId' in sanitizedUpdates) { - const nextGroupId = sanitizedUpdates.projectGroupId - if ( - typeof nextGroupId !== 'string' || - nextGroupId.trim().length === 0 || - !this.state.projectGroups.some((group) => group.id === nextGroupId) - ) { - sanitizedUpdates.projectGroupId = null - } - } - if ( - 'projectGroupOrder' in sanitizedUpdates && - (typeof sanitizedUpdates.projectGroupOrder !== 'number' || - !Number.isFinite(sanitizedUpdates.projectGroupOrder)) - ) { - delete sanitizedUpdates.projectGroupOrder - } - const externalWorktreeVisibilityLegacy = - 'externalWorktreeVisibility' in sanitizedUpdates && - repo.externalWorktreeVisibilityLegacy === undefined - ? isLegacyRepoForExternalWorktreeVisibility(repo) - : undefined - // Why: selected repo fields use `undefined` as an explicit clear signal, so delete them before assigning the patch. - if ( - 'issueSourcePreference' in sanitizedUpdates && - sanitizedUpdates.issueSourcePreference === undefined - ) { - delete repo.issueSourcePreference - delete sanitizedUpdates.issueSourcePreference - } - if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { - delete repo.worktreeBasePath - delete sanitizedUpdates.worktreeBasePath - } - if ( - 'externalWorktreeVisibility' in sanitizedUpdates && - (sanitizedUpdates.externalWorktreeVisibility === undefined || - sanitizedUpdates.externalWorktreeVisibility === null) - ) { - delete repo.externalWorktreeVisibility - repo.externalWorktreeVisibilityLegacy = false - delete sanitizedUpdates.externalWorktreeVisibility - } - if ( - 'agentWorktreeVisibility' in sanitizedUpdates && - sanitizedUpdates.agentWorktreeVisibility === null - ) { - delete repo.agentWorktreeVisibility - delete sanitizedUpdates.agentWorktreeVisibility - } - if ( - 'externalWorktreeVisibility' in sanitizedUpdates && - repo.externalWorktreeVisibilityLegacy === undefined - ) { - // Why: old persisted repos have no marker; stamp it on first visibility change so later hide/show keeps legacy safety. - repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy - } - if ( - 'externalWorktreeDiscoverySuppressedAt' in sanitizedUpdates && - (sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === undefined || - sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === null) - ) { - delete repo.externalWorktreeDiscoverySuppressedAt - delete sanitizedUpdates.externalWorktreeDiscoverySuppressedAt - } - if ( - 'sourceControlAi' in sanitizedUpdates && - (sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null) - ) { - delete repo.sourceControlAi - delete sanitizedUpdates.sourceControlAi - } else if ('sourceControlAi' in sanitizedUpdates) { - const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides( - sanitizedUpdates.sourceControlAi - ) - if (normalizedSourceControlAi === undefined) { - delete sanitizedUpdates.sourceControlAi - } else { - sanitizedUpdates.sourceControlAi = normalizedSourceControlAi - } - } - Object.assign(repo, sanitizedUpdates) - this.syncProjectHostSetupCompatibilityState() - this.scheduleSave() - return this.hydrateRepo(repo) - } - - private syncProjectHostSetupCompatibilityState(): void { - const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) - this.state.projects = compatibilityState.projects - this.state.projectHostSetups = compatibilityState.projectHostSetups - } - - private updateRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - repo: Repo, - updates: ProjectHostSetupUpdateArgs['updates'] - ): { setup: ProjectHostSetup; repo: Repo } | null { - if (updates.path !== undefined && updates.path !== repo.path) { - throw new Error( - 'Repo-backed project host setup paths must be changed by re-importing the project.' - ) - } - if (updates.setupState !== undefined && updates.setupState !== 'ready') { - throw new Error('Repo-backed project host setups cannot be marked unavailable.') - } - const repoUpdates: Parameters[1] = {} - if (updates.displayName !== undefined) { - repoUpdates.displayName = updates.displayName - } - if (updates.worktreeBasePath !== undefined) { - repoUpdates.worktreeBasePath = updates.worktreeBasePath - } - if (updates.kind !== undefined) { - repoUpdates.kind = updates.kind - } - if (updates.setupMethod === 'provisioned') { - throw new Error('Repo-backed project host setups cannot be marked provisioned.') - } - if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') { - repoUpdates.projectHostSetupMethod = updates.setupMethod - } - const updatedRepo = - Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo - if (!updatedRepo) { - return null - } - return { - setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup, - repo: updatedRepo - } - } - - private updateIndependentProjectHostSetup( - setup: ProjectHostSetup, - updates: ProjectHostSetupUpdateArgs['updates'] - ): ProjectHostSetup { - if (updates.displayName !== undefined) { - setup.displayName = updates.displayName.trim() || setup.displayName - } - if (updates.path !== undefined) { - setup.path = updates.path.trim() || setup.path - } - if (updates.worktreeBasePath !== undefined) { - const worktreeBasePath = updates.worktreeBasePath.trim() - if (worktreeBasePath) { - setup.worktreeBasePath = worktreeBasePath - } else { - delete setup.worktreeBasePath - } - } - if (updates.kind !== undefined) { - setup.kind = updates.kind - } - if (updates.gitUsername !== undefined) { - const gitUsername = updates.gitUsername.trim() - if (gitUsername) { - setup.gitUsername = gitUsername - } else { - delete setup.gitUsername - } - } - if (updates.setupState !== undefined) { - setup.setupState = updates.setupState - } - if (updates.setupMethod !== undefined) { - setup.setupMethod = updates.setupMethod - } - setup.updatedAt = Date.now() - this.scheduleSave() - return setup - } - - private hydrateRepo(repo: Repo): Repo { - const { - repoIcon: rawRepoIcon, - upstream: rawUpstream, - gitRemoteIdentity: rawGitRemoteIdentity, - sourceControlAi: rawSourceControlAi, - projectHostSetupMethod: rawProjectHostSetupMethod, - forkSyncMode: rawForkSyncMode, - customWorktreeVisibilitySources: rawCustomWorktreeVisibilitySources, - worktreeVisibilitySourcePreferences: rawWorktreeVisibilitySourcePreferences, - ...repoWithoutIcon - } = repo - const repoIcon = sanitizeRepoIcon(rawRepoIcon) - const upstream = sanitizeRepoUpstream(rawUpstream) - const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity) - const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi) - const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod) - const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode) - const customWorktreeVisibilitySources = normalizeCustomWorktreeVisibilitySources( - rawCustomWorktreeVisibilitySources - ) - const worktreeVisibilitySourcePreferences = normalizeWorktreeVisibilitySourcePreferences( - rawWorktreeVisibilitySourcePreferences - ) - // Why: never spawn git/gh username resolution in hydration — a stuck probe froze Windows startup for minutes (issue #7225); read only cache/persisted value. - const gitUsername = isFolderRepo(repo) - ? '' - : (this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '') - - return { - ...repoWithoutIcon, - ...(repoIcon !== undefined ? { repoIcon } : {}), - ...(upstream !== undefined ? { upstream } : {}), - ...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}), - ...(sourceControlAi !== undefined ? { sourceControlAi } : {}), - ...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}), - ...(forkSyncMode !== undefined ? { forkSyncMode } : {}), - ...(customWorktreeVisibilitySources !== undefined ? { customWorktreeVisibilitySources } : {}), - ...(worktreeVisibilitySourcePreferences !== undefined - ? { worktreeVisibilitySourcePreferences } - : {}), - kind: isFolderRepo(repo) ? 'folder' : 'git', - gitUsername, - hookSettings: { - ...getDefaultRepoHookSettings(), - ...repo.hookSettings, - scripts: { - ...getDefaultRepoHookSettings().scripts, - ...repo.hookSettings?.scripts - } - } - } - } - - // ── Sparse Presets ───────────────────────────────────────────────── - - // ── Mobile client tab selections ────────────────────────────────── - - getMobileClientTabSelections(): PersistedMobileClientTabSelections { - return this.state.mobileClientTabSelectionsByDeviceId ?? {} - } - - setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { - this.state.mobileClientTabSelectionsByDeviceId = next - this.scheduleSave() - } - - 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 ?? []) - .map((automation) => normalizeAutomationSessionReuse(automation)) - .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)] - .map((run) => ({ - ...run, - precheckResult: normalizeAutomationPrecheckResult(run.precheckResult) - })) - .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 schedulerOwner = getAutomationSchedulerOwner(repo) - const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? []) - const automation: Automation = { - id: randomUUID(), - name: input.name.trim() || 'Untitled automation', - prompt: input.prompt, - precheck: normalizeAutomationPrecheck(input.precheck), - agentId: input.agentId, - runContext: input.runContext ?? contexts.runContext, - sourceContext: input.sourceContext ?? contexts.sourceContext, - projectId: input.projectId, - executionTargetType, - executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', - schedulerOwner, - workspaceMode: input.workspaceMode, - workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, - baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, - setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode( - input.workspaceMode, - input.setupDecision - ), - reuseSession: input.workspaceMode === 'existing' ? (input.reuseSession ?? false) : false, - 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.recordFeatureInteraction('automation-created') - 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 schedulerOwner = getAutomationSchedulerOwner(repo) - const contexts = getAutomationContextsForRepo(repo, this.state.projectHostSetups ?? []) - 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, - precheck: Object.hasOwn(updates, 'precheck') - ? normalizeAutomationPrecheck(updates.precheck) - : normalizeAutomationPrecheck(current.precheck), - projectId: repoId, - runContext: Object.hasOwn(updates, 'runContext') - ? (updates.runContext ?? null) - : updates.projectId !== undefined - ? contexts.runContext - : (current.runContext ?? contexts.runContext), - sourceContext: Object.hasOwn(updates, 'sourceContext') - ? (updates.sourceContext ?? null) - : updates.projectId !== undefined - ? contexts.sourceContext - : (current.sourceContext ?? contexts.sourceContext), - executionTargetType, - executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', - schedulerOwner, - 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, - setupDecision: - workspaceMode === 'new_per_run' - ? Object.hasOwn(updates, 'setupDecision') - ? normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, updates.setupDecision) - : normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, current.setupDecision) - : undefined, - reuseSession: - workspaceMode === 'existing' - ? (updates.reuseSession ?? current.reuseSession ?? false) - : false, - 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() - // Why: retention prunes old runs, so the retained count isn't the ordinal — carry the number forward from the newest survivor. - const runNumber = nextAutomationRunNumber( - (this.state.automationRuns ?? []).filter((run) => run.automationId === automation.id) - ) - const run: AutomationRun = { - id: randomUUID(), - automationId: automation.id, - runNumber, - runContext: automation.runContext ?? null, - sourceContext: automation.sourceContext ?? null, - title: `${automation.name} run ${runNumber}`, - scheduledFor, - status: 'pending', - trigger, - workspaceId: automation.workspaceId, - workspaceDisplayName: this.getAutomationRunWorkspaceDisplayName(automation.workspaceId), - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: null, - terminalPaneKey: null, - terminalPtyId: null, - outputSnapshot: null, - precheckResult: null, - usage: null, - error: null, - startedAt: null, - dispatchedAt: null, - createdAt: now - } - this.state.automationRuns = pruneAutomationRuns([...(this.state.automationRuns ?? []), run]) - if (trigger === 'manual') { - this.recordFeatureInteraction('automation-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: Object.hasOwn(result, 'terminalSessionId') - ? (result.terminalSessionId ?? null) - : current.terminalSessionId, - terminalPaneKey: Object.hasOwn(result, 'terminalPaneKey') - ? normalizeAutomationRunTerminalPaneKey(result.terminalPaneKey) - : normalizeAutomationRunTerminalPaneKey(current.terminalPaneKey), - terminalPtyId: Object.hasOwn(result, 'terminalPtyId') - ? normalizeAutomationRunTerminalPtyId(result.terminalPtyId) - : normalizeAutomationRunTerminalPtyId(current.terminalPtyId), - outputSnapshot: Object.hasOwn(result, 'outputSnapshot') - ? normalizeAutomationRunOutputSnapshot(result.outputSnapshot) - : normalizeAutomationRunOutputSnapshot(current.outputSnapshot), - precheckResult: Object.hasOwn(result, 'precheckResult') - ? normalizeAutomationPrecheckResult(result.precheckResult) - : normalizeAutomationPrecheckResult(current.precheckResult), - 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 { - return this.state.worktreeMeta - } - - setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { - const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() - const updated = { ...existing, ...meta } - updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updated.linkedTaskSourceContext - ) - updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - updated.linkedWorkItem, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null - if (!updated.instanceId) { - updated.instanceId = randomUUID() - } - this.state.worktreeMeta[worktreeId] = updated - this.scheduleSave() - return updated - } - - removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { - // Persisted ownership beats stale live routing; hostId is only an ownerless fallback. - const owner = this.state.worktreeMeta[worktreeId]?.hostId ?? hostId - // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. - const partitions = new Set( - workspaceSessionPartitionIdsForHost(owner).filter((partition) => - this.hasPersistedWorkspaceSession(partition) - ) - ) - // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill - // partition that never held this worktree has no claim on the repo at all. - const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) - const fencedPartitions = new Set( - [...partitions].filter( - (partition) => - this.partitionOwnsWorktreeTabs(worktreeId, partition) || - (partition === ownerPartition && - !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) - ) - ) - delete this.state.worktreeMeta[worktreeId] - delete this.state.worktreeLineageById[worktreeId] - delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] - for (const partition of partitions) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { - advanceTerminalTopologyRevision: fencedPartitions.has(partition) - }) - } - this.scheduleSave() - } - - getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { - return this.state.worktreeLineageById[worktreeId] - } - - getAllWorktreeLineage(): Record { - 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() - } - - /** - * Re-key every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId` after the worktree folder (and its - * `${repoId}::${path}` id) was renamed on disk, so a refresh re-binds state instead of orphaning it. Records the old id on - * the new meta's `priorWorktreeIds` so session GC/hydration still recognizes PTY sessions minted under it. No-op when ids match. - * Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts. - */ - migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { - if (oldWorktreeId === newWorktreeId) { - return - } - const oldWorkspaceKey = worktreeWorkspaceKey(oldWorktreeId) - const newWorkspaceKey = worktreeWorkspaceKey(newWorktreeId) - const moveKey = ( - record: Record, - mapValue: (value: T) => T = (value) => value - ): boolean => { - if (!(oldWorktreeId in record)) { - return false - } - record[newWorktreeId] = mapValue(record[oldWorktreeId]) - delete record[oldWorktreeId] - return true - } - const withNewWorktreeId = (value: T): T => - value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value - const migrateSession = (session: WorkspaceSessionState | undefined): boolean => { - if (!session) { - return false - } - let sessionChanged = false - const moveSessionKey = ( - record: Record | undefined, - mapValue: (value: T) => T = (value) => value - ): boolean => { - if (!record) { - return false - } - let moved = false - const pairs: [string, string][] = [ - [oldWorktreeId, newWorktreeId], - [oldWorkspaceKey, newWorkspaceKey] - ] - for (const [oldKey, newKey] of pairs) { - if (!(oldKey in record)) { - continue - } - record[newKey] = mapValue(record[oldKey]) - delete record[oldKey] - moved = true - } - return moved - } - - sessionChanged = - moveSessionKey(session.tabsByWorktree, (tabs) => tabs.map(withNewWorktreeId)) || - sessionChanged - sessionChanged = - moveSessionKey(session.openFilesByWorktree, (files) => files.map(withNewWorktreeId)) || - sessionChanged - sessionChanged = moveSessionKey(session.activeFileIdByWorktree) || sessionChanged - sessionChanged = - moveSessionKey(session.browserTabsByWorktree, (workspaces) => - workspaces.map(withNewWorktreeId) - ) || sessionChanged - if (session.browserPagesByWorkspace) { - let pagesChanged = false - const nextPagesByWorkspace = { ...session.browserPagesByWorkspace } - for (const [workspaceId, pages] of Object.entries(nextPagesByWorkspace)) { - if (!pages.some((page) => page.worktreeId === oldWorktreeId)) { - continue - } - nextPagesByWorkspace[workspaceId] = pages.map(withNewWorktreeId) - pagesChanged = true - } - if (pagesChanged) { - session.browserPagesByWorkspace = nextPagesByWorkspace - sessionChanged = true - } - } - sessionChanged = moveSessionKey(session.activeBrowserTabIdByWorktree) || sessionChanged - sessionChanged = moveSessionKey(session.activeTabTypeByWorktree) || sessionChanged - sessionChanged = moveSessionKey(session.activeTabIdByWorktree) || sessionChanged - sessionChanged = - moveSessionKey(session.unifiedTabs, (tabs) => tabs.map(withNewWorktreeId)) || sessionChanged - sessionChanged = - moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || - sessionChanged - sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged - sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged - sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged - sessionChanged = - moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged - if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) { - session.activeWorktreeIdsOnShutdown = session.activeWorktreeIdsOnShutdown.map((id) => - id === oldWorktreeId ? newWorktreeId : id - ) - sessionChanged = true - } - if (session.activeWorktreeId === oldWorktreeId) { - session.activeWorktreeId = newWorktreeId - sessionChanged = true - } - if (session.activeWorkspaceKey === oldWorkspaceKey) { - session.activeWorkspaceKey = newWorkspaceKey - sessionChanged = true - } - if (session.sleepingAgentSessionsByPaneKey) { - let sleepingChanged = false - const nextSleeping = { ...session.sleepingAgentSessionsByPaneKey } - for (const [paneKey, record] of Object.entries(nextSleeping)) { - if (record.worktreeId !== oldWorktreeId) { - continue - } - nextSleeping[paneKey] = { ...record, worktreeId: newWorktreeId } - sleepingChanged = true - } - if (sleepingChanged) { - session.sleepingAgentSessionsByPaneKey = nextSleeping - sessionChanged = true - } - } - if (session.terminalSurfaceTombstonesByPaneKey) { - let tombstonesChanged = false - const nextTombstones = { ...session.terminalSurfaceTombstonesByPaneKey } - for (const [paneKey, tombstone] of Object.entries(nextTombstones)) { - if (tombstone.worktreeId !== oldWorktreeId) { - continue - } - nextTombstones[paneKey] = { ...tombstone, worktreeId: newWorktreeId } - tombstonesChanged = true - } - if (tombstonesChanged) { - session.terminalSurfaceTombstonesByPaneKey = nextTombstones - sessionChanged = true - } - } - return sessionChanged - } - - let changed = moveKey(this.state.worktreeMeta) - // Record the prior id so a session minted under it isn't reaped as an orphan. - const newMeta = this.state.worktreeMeta[newWorktreeId] - if (newMeta) { - const prior = newMeta.priorWorktreeIds ?? [] - if (!prior.includes(oldWorktreeId)) { - newMeta.priorWorktreeIds = [...prior, oldWorktreeId] - changed = true - } - } - - changed = moveKey(this.state.worktreeLineageById) || changed - const movedLineage = this.state.worktreeLineageById[newWorktreeId] - if (movedLineage && movedLineage.worktreeId === oldWorktreeId) { - movedLineage.worktreeId = newWorktreeId - } - // Why: children carry this as parentWorktreeId; keep the denormalized path-derived id consistent (parentWorktreeInstanceId is stable). - for (const lineage of Object.values(this.state.worktreeLineageById)) { - if (lineage.parentWorktreeId === oldWorktreeId) { - lineage.parentWorktreeId = newWorktreeId - changed = true - } - } - - if (oldWorkspaceKey in this.state.workspaceLineageByChildKey) { - const lineage = this.state.workspaceLineageByChildKey[oldWorkspaceKey] - this.state.workspaceLineageByChildKey[newWorkspaceKey] = { - ...lineage, - childWorkspaceKey: newWorkspaceKey - } - delete this.state.workspaceLineageByChildKey[oldWorkspaceKey] - changed = true - } - for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - if (lineage.parentWorkspaceKey === oldWorkspaceKey) { - this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] = { - ...lineage, - parentWorkspaceKey: newWorkspaceKey - } - changed = true - } - } - - changed = migrateSession(this.state.workspaceSession) || changed - for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { - changed = migrateSession(session) || changed - } - for (const selectionsByWorktree of Object.values( - this.state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - changed = moveKey(selectionsByWorktree) || changed - } - const showDotfiles = this.state.ui?.showDotfilesByWorktree - if (showDotfiles) { - changed = moveKey(showDotfiles) || changed - } - - if (changed) { - this.scheduleSave() - } - } - - getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { - return this.state.workspaceLineageByChildKey[childWorkspaceKey] - } - - getAllWorkspaceLineage(): Record { - return this.state.workspaceLineageByChildKey - } - - setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { - this.state.workspaceLineageByChildKey[lineage.childWorkspaceKey] = lineage - this.scheduleSave() - return lineage - } - - removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { - delete this.state.workspaceLineageByChildKey[childWorkspaceKey] - this.scheduleSave() - } - - private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { - const parentKey = folderWorkspaceKey(folderWorkspaceId) - for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - if (lineage.parentWorkspaceKey === parentKey) { - delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] - } - } - } - - // ── Settings ─────────────────────────────────────────────────────── - - getSettings(): GlobalSettings { - return this.state.settings - } - - onSettingsChanged( - listener: ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - ): () => void { - this.settingsChangeListeners.add(listener) - return () => { - this.settingsChangeListeners.delete(listener) - } - } - - private notifySettingsChanged( - updates: Partial, - originWebContentsId?: number - ): void { - for (const listener of this.settingsChangeListeners) { - listener(updates, this.state.settings, originWebContentsId) - } - } - - // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. - onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { - this.uiChangeListeners.add(listener) - return () => { - this.uiChangeListeners.delete(listener) - } - } - - private notifyUIChanged(): void { - if (this.uiChangeListeners.size === 0) { - return - } - const ui = this.getUI() - for (const listener of this.uiChangeListeners) { - listener(ui) - } - } - - updateSettings( - updates: Partial, - options: { notifyListeners?: boolean; originWebContentsId?: number } = {} - ): GlobalSettings { - const sanitizedUpdates = stripRetiredGlobalSettings(updates) - if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) - } - if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) - } - // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". - if ('minimizeToTrayOnClose' in updates) { - sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true - } - if ('showMenuBarIcon' in updates) { - sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true - } - // Why: the artifact publish capability must be an exact boolean on disk; no truthy value grants it. - if ('artifactSharingEnabled' in updates) { - sanitizedUpdates.artifactSharingEnabled = updates.artifactSharingEnabled === true - } - if ('disabledTuiAgents' in updates) { - sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) - } - if ('worktreeVisibilityDefaults' in updates) { - sanitizedUpdates.worktreeVisibilityDefaults = { - ...this.state.settings.worktreeVisibilityDefaults, - ...(normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) ?? { - external: 'hide' - }) - } - } - if ('agentDefaultArgs' in updates) { - sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } - if ('agentDefaultEnv' in updates) { - sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } - if ('terminalQuickCommands' in updates) { - sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands( - updates.terminalQuickCommands - ) - } - if ('terminalCustomThemes' in updates) { - sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes( - updates.terminalCustomThemes - ) - } - if ('terminalCursorStyle' in updates) { - Object.assign( - sanitizedUpdates, - normalizeTerminalCursorStyleDefault( - { terminalCursorStyle: updates.terminalCursorStyle }, - { preserveExplicitValue: true } - ) - ) - } - if ('terminalScrollbackRows' in updates) { - sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( - updates.terminalScrollbackRows - ) - } - if ( - 'terminalTuiScrollSensitivity' in updates || - 'terminalTuiScrollSensitivityDefaultedToOne' in updates - ) { - sanitizedUpdates.terminalTuiScrollSensitivityDefaultedToOne = true - } - if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { - const taskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: - 'visibleTaskProviders' in updates - ? updates.visibleTaskProviders - : this.state.settings.visibleTaskProviders, - defaultTaskSource: - 'defaultTaskSource' in updates - ? updates.defaultTaskSource - : this.state.settings.defaultTaskSource - }) - sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource - sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders - if ('visibleTaskProviders' in updates) { - sanitizedUpdates.visibleTaskProvidersDefaultedForJira = true - } - } - if ('autoRenameBranchFromWork' in updates || 'autoRenameBranchFromWorkDefaultedOn' in updates) { - sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true - } - if ('openInApplications' in updates) { - sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) - } - if ('terminalShortcutPolicy' in updates) { - sanitizedUpdates.terminalShortcutPolicy = normalizeTerminalShortcutPolicy( - updates.terminalShortcutPolicy - ) - } - if ('sourceControlGroupOrder' in updates) { - sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( - updates.sourceControlGroupOrder - ) - } - if ('appIcon' in updates) { - sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) - } - if ('uiLanguage' in updates) { - sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) - } - if ('prBotAuthorOverrides' in updates) { - // Why: every writer (desktop IPC, web RPC, migrations) hits this boundary, so the persisted list stays bounded and well-formed. - sanitizedUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( - updates.prBotAuthorOverrides - ) - } - if ('mobilePairingCustomAddress' in updates) { - sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( - updates.mobilePairingCustomAddress - ) - } - if ('mobilePairingCustomAddresses' in updates) { - sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses( - updates.mobilePairingCustomAddresses - ) - } - if ( - 'mobilePairingCustomAddress' in sanitizedUpdates || - 'mobilePairingCustomAddresses' in sanitizedUpdates - ) { - const mobilePairingCustomAddress = - 'mobilePairingCustomAddress' in sanitizedUpdates - ? sanitizedUpdates.mobilePairingCustomAddress - : this.state.settings.mobilePairingCustomAddress - if (mobilePairingCustomAddress) { - sanitizedUpdates.mobilePairingCustomAddresses = addMobilePairingCustomAddress( - sanitizedUpdates.mobilePairingCustomAddresses ?? - this.state.settings.mobilePairingCustomAddresses ?? - [], - mobilePairingCustomAddress - ) - } - } - const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate( - this.state.settings, - sanitizedUpdates - ) - if (historyWithPreviousLayout) { - sanitizedUpdates.workspaceDirHistory = historyWithPreviousLayout - } - // Why deep-merge telemetry: a partial update (e.g. flipping only `optedIn`) must not clobber siblings like `installId`. - const mergedTelemetry = - sanitizedUpdates.telemetry !== undefined - ? { ...this.state.settings.telemetry, ...sanitizedUpdates.telemetry } - : this.state.settings.telemetry - if ('sourceControlAi' in sanitizedUpdates) { - sanitizedUpdates.sourceControlAi = retireLegacyInstructionsForClearedTextActionRecipes( - sanitizedUpdates.sourceControlAi, - this.state.settings - ) - const normalizedSourceControlAi = normalizeSourceControlAiSettings( - sanitizedUpdates.sourceControlAi, - this.state.settings.commitMessageAi - ) - sanitizedUpdates.sourceControlAi = normalizedSourceControlAi - sanitizedUpdates.commitMessageAi = projectSourceControlAiToLegacyCommitMessageAi( - normalizedSourceControlAi, - this.state.settings.commitMessageAi - ) - } else if ('commitMessageAi' in sanitizedUpdates) { - sanitizedUpdates.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi( - this.state.settings.sourceControlAi, - sanitizedUpdates.commitMessageAi - ) - } - const previousSettings = this.state.settings - this.state.settings = { - ...this.state.settings, - ...sanitizedUpdates, - notifications: normalizeNotificationSettings({ - ...this.state.settings.notifications, - ...sanitizedUpdates.notifications - }), - ...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {}) - } - this.scheduleSave() - const changedUpdates = {} as Partial & Record - for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) { - if (!Object.is(previousSettings[key], this.state.settings[key])) { - changedUpdates[String(key)] = this.state.settings[key] - } - } - if (options.notifyListeners === true && Object.keys(changedUpdates).length > 0) { - this.notifySettingsChanged(changedUpdates, options.originWebContentsId) - } - return this.state.settings - } - - // ── UI State ─────────────────────────────────────────────────────── - - getUI(): PersistedState['ui'] { - const uiState = stripMainOwnedTelemetryMarkerFromUI(this.state.ui) - return { - ...getDefaultUIState(), - ...uiState, - groupBy: normalizeGroupBy(this.state.ui?.groupBy), - sortBy: normalizeSortBy(this.state.ui?.sortBy), - projectOrderBy: normalizeProjectOrderBy(this.state.ui?.projectOrderBy), - rightSidebarTab: normalizeRightSidebarTab(this.state.ui?.rightSidebarTab), - rightSidebarExplorerView: normalizeRightSidebarExplorerView( - this.state.ui?.rightSidebarExplorerView, - this.state.ui?.rightSidebarTab - ), - worktreeCardProperties: normalizeWorktreeCardProperties( - this.state.ui?.worktreeCardProperties - ), - agentActivityDisplayMode: normalizeAgentActivityDisplayMode( - this.state.ui?.agentActivityDisplayMode - ), - workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), - workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity), - workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( - this.state.ui?.workspaceBoardColumnWidth - ), - syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, - usagePercentageDisplay: normalizeUsagePercentageDisplay( - this.state.ui?.usagePercentageDisplay - ), - statusBarUsageMode: normalizeStatusBarUsageMode(this.state.ui?.statusBarUsageMode), - // Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires). - trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, - osc52ClipboardDefaultOnNoticePending: - this.state.ui?.osc52ClipboardDefaultOnNoticePending === true, - markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), - combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( - this.state.ui?.combinedDiffFileTreeWidth - ), - visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( - this.state.ui?.visibleWorkspaceHostIds - ), - workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), - manualRepoOrder: normalizeManualRepoOrder(this.state.ui?.manualRepoOrder), - browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( - this.state.ui?.browserDefaultZoomLevel - ), - showDotfilesByWorktree: normalizeShowDotfilesByWorktree( - this.state.ui?.showDotfilesByWorktree - ), - featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), - contextualToursSeenIds: normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds), - featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions), - activeView: this.activeViewPreference.get() - } - } - - updateUI(updates: Partial): void { - if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) - } - const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) - const { activeView, ...durableUpdates } = sanitizedUpdates - const activeViewChanged = this.activeViewPreference.set(activeView) - if (Object.keys(durableUpdates).length === 0) { - if (activeViewChanged) { - this.notifyUIChanged() - } - return - } - const currentUI = { - ...getDefaultUIState(), - ...stripMainOwnedTelemetryMarkerFromUI(this.state.ui) - } - const previousUI = { - ...this.getUI(), - // Why: the legacy field stays unchanged as a migration/downgrade - // fallback; the profile sidecar is authoritative in current builds. - activeView: currentUI.activeView - } - const nextRightSidebarTab = - sanitizedUpdates.rightSidebarTab !== undefined - ? normalizeRightSidebarTab(sanitizedUpdates.rightSidebarTab) - : normalizeRightSidebarTab(this.state.ui?.rightSidebarTab) - const nextRightSidebarExplorerView = - sanitizedUpdates.rightSidebarExplorerView !== undefined - ? normalizeRightSidebarExplorerView( - sanitizedUpdates.rightSidebarExplorerView, - nextRightSidebarTab - ) - : sanitizedUpdates.rightSidebarTab === 'search' - ? 'search' - : normalizeRightSidebarExplorerView( - this.state.ui?.rightSidebarExplorerView, - nextRightSidebarTab - ) - const nextUI = { - ...currentUI, - ...durableUpdates, - workspaceCleanup: mergeWorkspaceCleanupUIState( - currentUI.workspaceCleanup, - durableUpdates.workspaceCleanup - ), - groupBy: durableUpdates.groupBy - ? normalizeGroupBy(durableUpdates.groupBy) - : normalizeGroupBy(this.state.ui?.groupBy), - sortBy: durableUpdates.sortBy - ? normalizeSortBy(durableUpdates.sortBy) - : normalizeSortBy(this.state.ui?.sortBy), - projectOrderBy: updates.projectOrderBy - ? normalizeProjectOrderBy(updates.projectOrderBy) - : normalizeProjectOrderBy(this.state.ui?.projectOrderBy), - activeView: currentUI.activeView, - rightSidebarTab: nextRightSidebarTab, - rightSidebarExplorerView: nextRightSidebarExplorerView, - worktreeCardProperties: - sanitizedUpdates.worktreeCardProperties !== undefined - ? normalizeWorktreeCardProperties(sanitizedUpdates.worktreeCardProperties) - : normalizeWorktreeCardProperties(this.state.ui?.worktreeCardProperties), - agentActivityDisplayMode: - updates.agentActivityDisplayMode !== undefined - ? normalizeAgentActivityDisplayMode(updates.agentActivityDisplayMode) - : normalizeAgentActivityDisplayMode(this.state.ui?.agentActivityDisplayMode), - workspaceStatuses: - sanitizedUpdates.workspaceStatuses !== undefined - ? normalizeWorkspaceStatuses(sanitizedUpdates.workspaceStatuses) - : normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), - workspaceBoardOpacity: clampWorkspaceBoardOpacity( - sanitizedUpdates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity - ), - workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( - sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth - ), - syncTaskStatusFromWorkspaceBoard: - sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined - ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true - : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, - usagePercentageDisplay: normalizeUsagePercentageDisplay( - sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay - ), - statusBarUsageMode: normalizeStatusBarUsageMode( - sanitizedUpdates.statusBarUsageMode ?? this.state.ui?.statusBarUsageMode - ), - markdownTocPanelWidth: clampMarkdownTocPanelWidth( - sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth - ), - combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( - sanitizedUpdates.combinedDiffFileTreeWidth ?? this.state.ui?.combinedDiffFileTreeWidth - ), - visibleWorkspaceHostIds: - updates.visibleWorkspaceHostIds !== undefined - ? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds) - : normalizeVisibleExecutionHostIds(this.state.ui?.visibleWorkspaceHostIds), - workspaceHostOrder: - updates.workspaceHostOrder !== undefined - ? normalizeExecutionHostOrder(updates.workspaceHostOrder) - : normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), - manualRepoOrder: - updates.manualRepoOrder !== undefined - ? normalizeManualRepoOrder(updates.manualRepoOrder) - : normalizeManualRepoOrder(this.state.ui?.manualRepoOrder), - browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( - updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel - ), - showDotfilesByWorktree: - updates.showDotfilesByWorktree !== undefined - ? normalizeShowDotfilesByWorktree(updates.showDotfilesByWorktree) - : normalizeShowDotfilesByWorktree(this.state.ui?.showDotfilesByWorktree), - featureTipsSeenIds: - sanitizedUpdates.featureTipsSeenIds !== undefined - ? normalizeFeatureTipIds(sanitizedUpdates.featureTipsSeenIds) - : normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), - // Why: renderer and paired clients can mark different tours seen from stale snapshots; union so completed tours stay suppressed. - contextualToursSeenIds: - updates.contextualToursSeenIds !== undefined - ? mergeContextualTourSeenIds( - this.state.ui?.contextualToursSeenIds, - updates.contextualToursSeenIds - ) - : normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds), - // Why: runtime RPCs and the renderer both record education state; merge so a stale renderer snapshot can't erase runtime-only interactions. - featureInteractions: - sanitizedUpdates.featureInteractions !== undefined - ? mergeFeatureInteractions( - this.state.ui?.featureInteractions, - sanitizedUpdates.featureInteractions - ) - : normalizeFeatureInteractions(this.state.ui?.featureInteractions) - } - if (persistedUIValuesEqual(previousUI, nextUI)) { - if (activeViewChanged) { - this.notifyUIChanged() - } - return - } - this.state.ui = nextUI - this.scheduleSave() - this.notifyUIChanged() - } - - recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { - const featureInteractions = normalizeFeatureInteractions(this.state.ui?.featureInteractions) - const telemetryBuckets = normalizeFeatureInteractionTelemetryBuckets( - this.state.featureInteractionTelemetryBuckets - ) - const existing = featureInteractions[id] - const previousCount = existing?.interactionCount ?? 0 - const nextCount = previousCount + 1 - const previousBucket = getFeatureInteractionUsageBucket(previousCount) - const nextBucket = getFeatureInteractionUsageBucket(nextCount) - const lastEmittedBucket = telemetryBuckets[id] ?? null - const shouldEmit = - nextBucket !== null && - (lastEmittedBucket === null || - compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0) - - this.state.ui = { - ...this.state.ui, - featureInteractions: { - ...featureInteractions, - [id]: { - firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), - interactionCount: nextCount - } - } - } - this.state.featureInteractionTelemetryBuckets = shouldEmit - ? { ...telemetryBuckets, [id]: nextBucket } - : telemetryBuckets - this.scheduleSave() - // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. - if (!existing) { - this.notifyUIChanged() - } - - if (shouldEmit) { - track('feature_interaction_usage_bucket_reached', { - feature_id: id, - feature_category: getFeatureInteractionCategory(id), - count_bucket: nextBucket, - bucket_source: - lastEmittedBucket === null && previousBucket !== null && previousBucket === nextBucket - ? 'observed_existing' - : 'crossed_now', - ...getCohortAtEmit() - }) - } - return this.getUI() - } - - // ── Onboarding ──────────────────────────────────────────────────── - - getOnboarding(): PersistedState['onboarding'] { - const defaults = getDefaultOnboardingState() - return { - ...defaults, - ...this.state.onboarding, - checklist: { - ...defaults.checklist, - ...this.state.onboarding?.checklist - } - } - } - - updateOnboarding( - updates: Partial> & { - checklist?: Partial - } - ): 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 { - // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. - this.state.githubCache = cache - this.githubCacheDirty = true - this.githubCacheGeneration += 1 - } - - // ── Workspace Session ───────────────────────────────────────────── - - /** Resolve an execution host argument to a canonical id; unknown/empty falls back to 'local' for legacy callers. */ - private resolveHostId(hostId?: string | null): ExecutionHostId { - return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - } - - getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - return this.state.workspaceSession ?? getDefaultWorkspaceSession() - } - return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() - } - - /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ - private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { - return ( - hostId === LOCAL_EXECUTION_HOST_ID || - this.state.workspaceSessionsByHostId?.[hostId] !== undefined - ) - } - - getWorkspaceSessionHostIds(): ExecutionHostId[] { - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { - const hostId = normalizeExecutionHostId(key) - if (hostId) { - hostIds.add(hostId) - } - } - return [...hostIds] - } - - readTerminalScrollbackSnapshot(ref: string): string | null { - return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) - } - - /** Resolve the worktree a terminal tab belongs to; more reliable than agent-echoed hook fields. */ - getWorktreeIdForTab(tabId: string): string | undefined { - return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) - } - - setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - removeWorkspaceSessionStateForWorktree( - worktreeId: string, - hostId?: ExecutionHostId | null, - options: { advanceTerminalTopologyRevision?: boolean } = {} - ): void { - for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) - } - } - - private removeWorkspaceSessionOwnerInPartition( - worktreeId: string, - resolved: ExecutionHostId, - options: { advanceTerminalTopologyRevision?: boolean } - ): void { - if (!this.hasPersistedWorkspaceSession(resolved)) { - return - } - const current = this.getWorkspaceSession(resolved) - const session = removeWorkspaceSessionOwner(current, worktreeId, { - advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true - }) - if (!session) { - return - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = session - } else { - // Host scoping matters because identical repo/path ids may exist on two servers. - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: session - } - } - this.scheduleSave() - } - - /** Whether a partition still holds terminal membership for `worktreeId`. */ - private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined - } - - /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ - private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} - return Object.entries(tabsByWorktree).some( - ([id, tabs]) => - id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 - ) - } - - stageWorkspaceSessionBeforeUnload( - session: PersistedState['workspaceSession'], - hostId?: string | null - ): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session, true) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ - private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { - // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. - session = sanitizeWorkspaceSessionTerminalRetirements( - session, - this.state.workspaceSessionsByHostId?.[hostId] - ) - const pruned = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: pruned - } - this.scheduleSave() - } - - private setLocalWorkspaceSession( - session: PersistedState['workspaceSession'], - deferSnapshotFiles = false - ): void { - const prior = this.state.workspaceSession - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) - session = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - - // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. - 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) { - registerPersistedPaneKeyAlias(entry) - } - 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() - 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 may 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 write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. - const buffersByLeafId = preserveMissingLeafRecordEntries( - priorLayout.buffersByLeafId, - layout.buffersByLeafId, - liveLeafIds - ) - const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( - priorLayout.scrollbackRefsByLeafId, - layout.scrollbackRefsByLeafId, - liveLeafIds - ) - const titlesByLeafId = preserveMissingLeafRecordEntries( - priorLayout.titlesByLeafId, - layout.titlesByLeafId, - liveLeafIds - ) - if (buffersByLeafId) { - layout.buffersByLeafId = buffersByLeafId - } - if (scrollbackRefsByLeafId) { - layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId - } - if (titlesByLeafId) { - layout.titlesByLeafId = titlesByLeafId - } - } - } - } - session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - if (!deferSnapshotFiles) { - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - session, - this.terminalScrollbackSnapshotStorage - ) - session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots( - prior, - session, - this.terminalScrollbackSnapshotStorage - ) - } - this.state.workspaceSession = session - if (deferSnapshotFiles) { - this.enqueueTerminalScrollbackSnapshotWork(prior, session) - } - this.scheduleSave() - } - - private enqueueTerminalScrollbackSnapshotWork( - prior: WorkspaceSessionState | undefined, - staged: WorkspaceSessionState - ): void { - const previous = this.pendingSnapshotFileWork ?? Promise.resolve() - const work = previous - .then(async () => { - if (this.state.workspaceSession !== staged) { - if (this.state.workspaceSession) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - this.state.workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - } - return - } - const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( - staged, - this.terminalScrollbackSnapshotStorage - ) - const current = - this.state.workspaceSession === staged ? migrated : this.state.workspaceSession - if (this.state.workspaceSession === staged) { - this.state.workspaceSession = migrated - } else if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - migrated, - current, - this.terminalScrollbackSnapshotStorage - ) - } - if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - current, - this.terminalScrollbackSnapshotStorage - ) - } - }) - .catch((error) => { - console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) - }) - .finally(() => { - if (this.pendingSnapshotFileWork === work) { - this.pendingSnapshotFileWork = null - } - }) - this.pendingSnapshotFileWork = work - } - - patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. - let next: WorkspaceSessionState = { - ...this.getWorkspaceSession(resolved), - ...patch - } - if (workspaceSessionPatchNeedsFullNormalization(patch)) { - this.setWorkspaceSession(next, resolved) - return - } - if (Object.hasOwn(patch, 'browserUrlHistory')) { - next = pruneWorkspaceSessionBrowserHistory(next) - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = next - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: next - } - } - this.scheduleSave() - } - - private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { - const leafIds = new Set() - 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 getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { - try { - return toRelaySshPtyId(targetId, ptyId) - } catch { - return ptyId - } - } - - private getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { - return toRelaySshPtyId(targetId, ptyId) - } - - private sshRemotePtyLeaseMatchesBinding( - lease: SshRemotePtyLease, - binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - } - ): boolean { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) - if (lease.ptyId !== bindingPtyId) { - return false - } - // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't 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 { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(binding.targetId, binding.ptyId) - if (lease.targetId !== binding.targetId || lease.ptyId !== bindingPtyId) { - return false - } - // Why: target removal is destructive; scrub matching bindings before deleting the lease, else removing the tombstone can revive stale PTY ids. - 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: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). - persistPtyBinding( - args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - startupCwd?: string - expectedBinding?: { ptyId: string; incarnationId?: string } - expectedSourceBinding?: PtyBindingSourceExpectation - }, - hostId?: string | null - ): boolean { - const resolvedHostId = this.resolveHostId(hostId) - const session = this.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - if (args.expectedSourceBinding) { - const expected = args.expectedSourceBinding - if (expected.tabId !== args.tabId) { - return false - } - const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId - ) - const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] - const sourcePaneKey = `${expected.tabId}:${expected.leafId}` - if ( - !sourceTab || - sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || - !layoutContainsLeafId(sourceLayout.root, expected.leafId) || - (expected.incarnationId !== undefined && - session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) - ) { - return false - } - } - if (args.expectedBinding) { - const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId - ) - const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] - if ( - !tab || - boundPtyId !== args.expectedBinding.ptyId || - session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId - ) { - return false - } - } - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: session - } - } - const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const reconciledIncarnation = - args.expectedBinding !== undefined && - args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - const establishesSplitAuthority = args.expectedSourceBinding !== undefined - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesSplitAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - const restoreSession = (): void => { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = sessionBeforeBinding - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = args.ptyId - } else { - terminalMembershipChanged = true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - 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 { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } - } - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - - // ── 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>): SshTarget | null { - const target = this.state.sshTargets?.find((t) => t.id === id) - if (!target) { - return null - } - const normalized = normalizeSshTarget({ ...target, ...updates }) - Object.assign(target, updates, normalized) - if (!Object.hasOwn(normalized, 'relayGracePeriodSeconds')) { - delete target.relayGracePeriodSeconds - } - if (!Object.hasOwn(normalized, 'systemSshConnectionReuse')) { - delete target.systemSshConnectionReuse - } - this.scheduleSave() - return { ...target } - } - - removeSshTarget(id: string): void { - const targets = this.state.sshTargets ?? [] - const recoveries = this.state.sshPtyConsumerRecoveries ?? [] - const nextTargets = targets.filter((target) => target.id !== id) - const nextRecoveries = recoveries.filter((record) => record.targetId !== id) - if (nextTargets.length === targets.length && nextRecoveries.length === recoveries.length) { - return - } - this.state.sshTargets = nextTargets - this.state.sshPtyConsumerRecoveries = nextRecoveries - this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id)) - this.scheduleSave() - } - - // ── Live Claude PTY sessions ─────────────────────────────────────── - - getClaudeLivePtySessionIds(): string[] { - return [...(this.state.claudeLivePtySessionIds ?? [])] - } - - addClaudeLivePtySessionId(sessionId: string): void { - if (sessionId.length === 0 || sessionId.length > 512) { - return - } - const ids = this.state.claudeLivePtySessionIds ?? [] - if (ids.includes(sessionId)) { - return - } - // Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters. - this.state.claudeLivePtySessionIds = [...ids, sessionId].slice(-MAX_CLAUDE_LIVE_PTY_SESSION_IDS) - // Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch. - this.flush() - } - - removeClaudeLivePtySessionId(sessionId: string): void { - const ids = this.state.claudeLivePtySessionIds ?? [] - if (!ids.includes(sessionId)) { - return - } - this.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId) - this.scheduleSave() - } - - /** Compacted, so callers get a watermark plus the names above it rather than every spent name. - * Copied because the array is stored state. */ - getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByRepo?.[repoId] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByNamespace?.[namespaceKey] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - /** Records a generated workspace name as spent for this repo. Called with the name main actually - * used, not the one the renderer proposed — the create path can advance past it on collision. */ - addRetiredWorktreeName(repoId: string, name: string): void { - const normalized = normalizeRetirableGeneratedName(name) - if (!repoId || !normalized) { - return - } - this.applyRetiredWorktreeNames(repoId, [normalized]) - } - - /** Seeds retirements discovered by the one-time backfill. Merges rather than replaces so a - * concurrent create during backfill is not lost. Returns true when anything was added. */ - mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - if (!repoId) { - return false - } - const incoming = new Set() - for (const name of names) { - const normalized = normalizeRetirableGeneratedName(name) - if (normalized) { - incoming.add(normalized) - } - } - return incoming.size > 0 && this.applyRetiredWorktreeNames(repoId, incoming) - } - - mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { - if (!namespaceKey) { - return false - } - const normalized = new Set() - for (const name of names) { - const candidate = normalizeRetirableGeneratedName(name) - if (candidate) { - normalized.add(candidate) - } - } - const next = addRetiredNames( - this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), - normalized - ) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByNamespace ??= {} - this.state.retiredWorktreeNamesByNamespace[namespaceKey] = next - this.scheduleSave() - return true - } - - private applyRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - const next = addRetiredNames(this.getRetiredWorktreeNameRegistry(repoId), names) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByRepo ??= {} - this.state.retiredWorktreeNamesByRepo[repoId] = next - this.scheduleSave() - return true - } - - getDeletedSshConfigAliases(): string[] { - return [...(this.state.deletedSshConfigAliases ?? [])] - } - - addDeletedSshConfigAlias(alias: string): void { - this.state.deletedSshConfigAliases ??= [] - if (!this.state.deletedSshConfigAliases.includes(alias)) { - this.state.deletedSshConfigAliases.push(alias) - this.scheduleSave() - } - } - - removeDeletedSshConfigAlias(alias: string): void { - const current = this.state.deletedSshConfigAliases - if (!current || !current.includes(alias)) { - return - } - this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias) - this.scheduleSave() - } - - clearDeletedSshConfigAliases(): void { - if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) { - this.state.deletedSshConfigAliases = [] - this.scheduleSave() - } - } - - getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { - return [...(this.state.removedSshTargetTombstones ?? [])] - } - - addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { - const existing = this.state.removedSshTargetTombstones ?? [] - // Why: dedupe by oldTargetId so re-removing the same id can't stack duplicate tombstones; newest wins. - const filtered = existing.filter((t) => t.oldTargetId !== tombstone.oldTargetId) - // Cap the history so pathological churn can't grow the state file unbounded. - this.state.removedSshTargetTombstones = [...filtered, tombstone].slice( - -MAX_REMOVED_SSH_TARGET_TOMBSTONES - ) - this.scheduleSave() - } - - removeRemovedSshTargetTombstone(oldTargetId: string): void { - const existing = this.state.removedSshTargetTombstones - if (!existing?.some((t) => t.oldTargetId === oldTargetId)) { - return - } - this.state.removedSshTargetTombstones = existing.filter((t) => t.oldTargetId !== oldTargetId) - this.scheduleSave() - } - - /** - * Re-point every repo and worktree meta pinned to a removed SSH target id onto - * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. - */ - reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { - if (oldTargetId === newTargetId) { - return [] - } - const oldHostId = toSshExecutionHostId(oldTargetId) - const newHostId = toSshExecutionHostId(newTargetId) - const repoIds = new Set() - for (const repo of this.state.repos) { - const matchesConnection = repo.connectionId === oldTargetId - const matchesHost = repo.executionHostId === oldHostId - if (!matchesConnection && !matchesHost) { - continue - } - if (matchesConnection) { - repo.connectionId = newTargetId - } - // Why: don't stamp executionHostId where it was unset — addRemoteRepoFromPath repos derive the host from connectionId. - if (matchesHost) { - repo.executionHostId = newHostId - } - repoIds.add(repo.id) - } - // Re-point worktree metas whose hostId pointed at the old SSH host. - let metaChanged = false - for (const meta of Object.values(this.state.worktreeMeta)) { - if (meta.hostId === oldHostId) { - meta.hostId = newHostId - metaChanged = true - } - } - // Why: any carrier still holding the old id later throws `SSH target not found` (STA-1468); migrate them all. - let carrierChanged = migrateWorkspaceSessionSshTargetId( - this.state.workspaceSession, - oldTargetId, - newTargetId - ) - for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { - if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) { - carrierChanged = true - } - } - // Why: partitions are read by host id; re-key from the removed id to the new one (keep new if it already exists). - const partitions = this.state.workspaceSessionsByHostId - const oldPartition = partitions?.[oldHostId] - if (partitions && oldPartition) { - delete partitions[oldHostId] - partitions[newHostId] ??= oldPartition - carrierChanged = true - } - if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) { - carrierChanged = true - } - for (const lease of this.state.sshRemotePtyLeases ?? []) { - if (lease.targetId === oldTargetId) { - lease.targetId = newTargetId - carrierChanged = true - } - } - const recoveries = this.state.sshPtyConsumerRecoveries ?? [] - const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId) - if (retainedRecoveries.length !== recoveries.length) { - this.state.sshPtyConsumerRecoveries = retainedRecoveries - this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId)) - carrierChanged = true - } - let setupsChanged = false - const keptSetups: ProjectHostSetup[] = [] - for (const setup of this.state.projectHostSetups) { - if (setup.hostId !== oldHostId) { - keptSetups.push(setup) - continue - } - const duplicate = this.state.projectHostSetups.some( - (entry) => - entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId - ) - // Why: drop the old ghost row that would violate (projectId, hostId) uniqueness with the re-added host's setup. - if (duplicate) { - setupsChanged = true - continue - } - setup.hostId = newHostId - setup.updatedAt = Date.now() - keptSetups.push(setup) - setupsChanged = true - } - if (setupsChanged) { - this.state.projectHostSetups = keptSetups - } - // Why: repo-row and host-setup rewrites affect host-setup compatibility; meta-only rewrites don't, so gate the sync here. - if (repoIds.size > 0 || setupsChanged) { - this.syncProjectHostSetupCompatibilityState() - } - if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) { - this.scheduleSave() - } - return [...repoIds] - } - - // ── SSH PTY Consumer Recovery ────────────────────────────────────── - - getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { - const record = (this.state.sshPtyConsumerRecoveries ?? []).find( - (candidate) => candidate.targetId === targetId - ) - if ( - record && - this.protectedSecrets.isSealed(sshPtyOwnerLeaseSecretSlot(record.targetId), record.ownerLease) - ) { - return null - } - return record ? structuredClone(record) : null - } - - async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { - const normalized = normalizeSshPtyConsumerRecovery(record) - if (!normalized) { - throw new Error('Invalid SSH PTY consumer recovery record') - } - const recoveries = this.state.sshPtyConsumerRecoveries ?? [] - this.state.sshPtyConsumerRecoveries = [ - ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), - normalized - ] - await this.flushSshPtyConsumerRecovery() - } - - async removeSshPtyConsumerRecovery(targetId: string): Promise { - const recoveries = this.state.sshPtyConsumerRecoveries ?? [] - const next = recoveries.filter((record) => record.targetId !== targetId) - if (next.length === recoveries.length) { - return - } - this.state.sshPtyConsumerRecoveries = next - this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) - await this.flushSshPtyConsumerRecovery() - } - - private async flushSshPtyConsumerRecovery(): Promise { - // Why: ownership must be durable before relay setup continues, but this runs on the live - // establish/reconnect path — a sync flush would park the main thread on a stalled profile mount. - // Why not caught here: the failure must reach the awaiting caller. - await this.flushDurableStateOrThrowAsync() - } - - // ── 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 & - Partial> - ): void { - this.state.sshRemotePtyLeases ??= [] - const normalizedLease = { ...lease } - if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) { - delete normalizedLease.leafId - } - // Why: store target-local pty ids in leases so reconnect can call relay pty.attach with raw ids (app ids are global). - normalizedLease.ptyId = this.getRelayPtyIdForSshLeaseStorage( - normalizedLease.targetId, - normalizedLease.ptyId - ) - const now = Date.now() - const existingIndex = this.state.sshRemotePtyLeases.findIndex( - (entry) => - entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId - ) - const existing = existingIndex !== -1 ? this.state.sshRemotePtyLeases[existingIndex] : undefined - const next: SshRemotePtyLease = { - ...existing, - ...normalizedLease, - createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, - updatedAt: normalizedLease.updatedAt ?? now - } - if (existingIndex !== -1) { - this.state.sshRemotePtyLeases[existingIndex] = next - } else { - this.state.sshRemotePtyLeases.push(next) - } - this.flush() - } - - markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { - if (this.updateSshRemotePtyLeaseStates(targetId, state)) { - this.flush() - } - } - - // Why no write of its own: the committed quit path calls this immediately before the final store - // flush, and that flush is what persists it. A durable write here would race the flush and be - // rejected the moment it latches, which is exactly how an attached lease used to survive quit. - markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { - this.updateSshRemotePtyLeaseStates(targetId, state) - } - - async markSshRemotePtyLeasesAsync( - targetId: string, - state: SshRemotePtyLease['state'] - ): Promise { - if (this.updateSshRemotePtyLeaseStates(targetId, state)) { - await this.flushDurableStateOrThrowAsync() - } - } - - async markSshRemotePtyLeasesAttachedAsync( - targetId: string, - ptyIds: readonly string[] - ): Promise { - const relayPtyIds = new Set( - ptyIds.map((ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId)) - ) - if (this.updateSshRemotePtyLeaseStates(targetId, 'attached', relayPtyIds)) { - await this.flushDurableStateOrThrowAsync() - } - } - - private updateSshRemotePtyLeaseStates( - targetId: string, - state: SshRemotePtyLease['state'], - ptyIds?: ReadonlySet - ): boolean { - const now = Date.now() - let changed = false - const shouldClearBindings = state === 'terminated' || state === 'expired' - const leasesToClear: SshRemotePtyLease[] = [] - this.state.sshRemotePtyLeases ??= [] - for (const lease of this.state.sshRemotePtyLeases) { - if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) { - continue - } - if (state === 'attached' && (lease.state === 'terminated' || lease.state === 'expired')) { - continue - } - if (state === 'detached' && lease.state !== 'attached') { - continue - } - if (lease.state !== state) { - lease.state = state - lease.updatedAt = now - if (state === 'attached') { - lease.lastAttachedAt = now - } else if (state === 'detached') { - lease.lastDetachedAt = now - } - changed = true - } - if (shouldClearBindings) { - leasesToClear.push(lease) - } - } - const bindingsChanged = shouldClearBindings - ? this.clearSshRemotePtyBindingsForLeases(targetId, leasesToClear) - : false - return changed || bindingsChanged - } - - markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { - const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) - const lease = this.state.sshRemotePtyLeases?.find( - (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId - ) - if (!lease) { - return - } - const shouldClearBindings = state === 'terminated' || state === 'expired' - if (lease.state === state) { - if (shouldClearBindings && this.clearSshRemotePtyBindingsForLeases(targetId, [lease])) { - this.flush() - } - return - } - const now = Date.now() - lease.state = state - lease.updatedAt = now - if (state === 'attached') { - lease.lastAttachedAt = now - } else if (state === 'detached') { - lease.lastDetachedAt = now - } - if (shouldClearBindings) { - this.clearSshRemotePtyBindingsForLeases(targetId, [lease]) - } - this.flush() - } - - removeSshRemotePtyLease(targetId: string, ptyId: string): void { - const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) - const leases = (this.state.sshRemotePtyLeases ?? []).filter( - (lease) => lease.targetId === targetId && lease.ptyId === relayPtyId - ) - const before = this.state.sshRemotePtyLeases?.length ?? 0 - this.clearSshRemotePtyBindingsForLeases(targetId, leases) - this.state.sshRemotePtyLeases = (this.state.sshRemotePtyLeases ?? []).filter( - (lease) => lease.targetId !== targetId || lease.ptyId !== relayPtyId - ) - 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[] - ): boolean { - if (!leases?.length) { - return false - } - let changed = false - const sessions = new Set( - [ - this.state.workspaceSession, - this.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)] - ].filter((session): session is WorkspaceSessionState => Boolean(session)) - ) - for (const session of sessions) { - 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() - } - return changed - } - - // ── Flush (for shutdown) ─────────────────────────────────────────── - - flush(): void { - if (this.quitFlushStarted) { - return - } - try { - this.flushOrThrow() - } catch (err) { - console.error('[persistence] Failed to flush state:', err) - } - try { - this.flushActiveViewPreferenceOrThrow() - } catch (err) { - console.error('[active-view] Failed to flush preference:', err) - } - this.writeGithubCacheSnapshotSync() - } - - /** - * Async twin of flush() for the quit path. - * - * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron - * main thread. On a stalled network profile mount that syscall is uninterruptible, so the - * app stops repainting and Force Quit stops working — and no main-thread deadline can - * bound it, because the deadline's own timer is stuck behind the same block. - * - * Never throws — it joins the quit teardown barrier, where a rejection is noise. - */ - flushAsync(): Promise { - if (this.quitFlushPromise) { - return this.quitFlushPromise - } - this.quitFlushStarted = true - this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) - return this.quitFlushPromise - } - - flushPendingAsync(): Promise { - // Best-effort callers must not livelock while the live app keeps mutating state. - return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) - } - - flushPendingOrThrowAsync( - options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} - ): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - return Promise.reject(new Error('Cannot flush while persistence is finalized')) - } - return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) - } - - // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are - // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. - private async flushDurableStateOrThrowAsync(): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - throw new Error('Cannot flush while persistence is finalized') - } - for (;;) { - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - await this.enqueueWrite() - if (generation === this.writeGeneration) { - break - } - } - } - - private async flushCurrentStateAsync( - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false - ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - try { - await this.enqueueWrite() - } catch (error) { - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - throw error - } - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - this.lastDurableWriteGeneration >= requiredDurableGeneration - ) { - break - } - continue - } - if (generation === this.writeGeneration) { - break - } - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private async writeGithubCacheSnapshotAsync( - drainToStableGeneration = true, - signal?: AbortSignal - ): Promise { - if (!this.githubCacheDirty) { - return - } - const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup - const nextWrite = previousWrite - .then(async () => { - while (this.githubCacheDirty) { - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - const generation = this.githubCacheGeneration - const cacheFile = getGithubCacheFile(this.dataFile) - const tmpFile = durableWriteTempPath(cacheFile) - let renamed = false - try { - await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - if (generation === this.githubCacheGeneration) { - await rename(tmpFile, cacheFile) - renamed = true - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - if (!drainToStableGeneration) { - break - } - } - }) - .catch((err) => { - console.warn('[persistence] Failed to write github cache snapshot:', err) - }) - .finally(() => { - if (this.pendingGithubCacheWrite === nextWrite) { - this.pendingGithubCacheWrite = null - } - }) - this.pendingGithubCacheWrite = nextWrite - await nextWrite - } - - // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. - freezeWrites(): void { - this.writesFrozen = true - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private writeGithubCacheSnapshotSync(): void { - if (!this.githubCacheDirty) { - return - } - if (this.pendingGithubCacheWrite) { - void this.writeGithubCacheSnapshotAsync() - return - } - const cacheFile = getGithubCacheFile(this.dataFile) - const generation = this.githubCacheGeneration - const tmpFile = durableWriteTempPath(cacheFile) - try { - writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - renameSync(tmpFile, cacheFile) - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } catch (err) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup. - } - console.warn('[persistence] Failed to write github cache snapshot:', err) - } - } -} - -function getDefaultWorktreeMeta(): WorktreeMeta { - return { - instanceId: randomUUID(), - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null, - linkedWorkItem: null, - linkedTaskSourceContext: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: Date.now(), - lastActivityAt: 0, - workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID - } -} +export { Store, type PtyBindingSourceExpectation } from './persistence/loading-store/store' +export { + getCanonicalUserDataPath, + initDataPath, + migrateMobilePairingDataToCanonicalUserDataPath +} from './persistence/loading-store/user-data-path' +export { normalizeRightSidebarTab } from './persistence/applying-settings/ui-selection-normalization' +export { sanitizeOnboardingUpdate } from './persistence/applying-settings/onboarding-normalization' diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts new file mode 100644 index 00000000000..935f11382b3 --- /dev/null +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -0,0 +1,72 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { + compareFeatureInteractionUsageBuckets, + getFeatureInteractionCategory, + getFeatureInteractionUsageBucket, + normalizeFeatureInteractions, + normalizeFeatureInteractionTelemetryBuckets, + type FeatureInteractionId +} from '../../../shared/feature-interactions' +import { track } from '../../telemetry/client' +import { getCohortAtEmit } from '../../telemetry/cohort-classifier' + +export type FeatureInteractionOperations = { + state: StoreOwnedPersistedState + scheduleSave: () => void + notifyUIChanged: () => void + getUI: () => PersistedState['ui'] +} + +export function recordFeatureInteraction( + operations: FeatureInteractionOperations, + id: FeatureInteractionId +): PersistedState['ui'] { + const featureInteractions = normalizeFeatureInteractions(operations.state.ui?.featureInteractions) + const telemetryBuckets = normalizeFeatureInteractionTelemetryBuckets( + operations.state.featureInteractionTelemetryBuckets + ) + const existing = featureInteractions[id] + const previousCount = existing?.interactionCount ?? 0 + const nextCount = previousCount + 1 + const previousBucket = getFeatureInteractionUsageBucket(previousCount) + const nextBucket = getFeatureInteractionUsageBucket(nextCount) + const lastEmittedBucket = telemetryBuckets[id] ?? null + const shouldEmit = + nextBucket !== null && + (lastEmittedBucket === null || + compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0) + + operations.state.ui = { + ...operations.state.ui, + featureInteractions: { + ...featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: nextCount + } + } + } + operations.state.featureInteractionTelemetryBuckets = shouldEmit + ? { ...telemetryBuckets, [id]: nextBucket } + : telemetryBuckets + operations.scheduleSave() + // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. + if (!existing) { + operations.notifyUIChanged() + } + + if (shouldEmit) { + track('feature_interaction_usage_bucket_reached', { + feature_id: id, + feature_category: getFeatureInteractionCategory(id), + count_bucket: nextBucket, + bucket_source: + lastEmittedBucket === null && previousBucket !== null && previousBucket === nextBucket + ? 'observed_existing' + : 'crossed_now', + ...getCohortAtEmit() + }) + } + return operations.getUI() +} diff --git a/src/main/persistence/applying-settings/onboarding-normalization.ts b/src/main/persistence/applying-settings/onboarding-normalization.ts new file mode 100644 index 00000000000..6e9813d71d4 --- /dev/null +++ b/src/main/persistence/applying-settings/onboarding-normalization.ts @@ -0,0 +1,214 @@ +import type { + OnboardingChecklistState, + OnboardingOutcome, + OnboardingState +} from '../../../shared/onboarding-state-types' +import type { NotificationSettings } from '../../../shared/notification-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { + getDefaultNotificationSettings, + getDefaultOnboardingState, + ONBOARDING_FINAL_STEP, + ONBOARDING_FLOW_VERSION +} from '../../../shared/constants' + +export function normalizeNotificationSettings(value: unknown): NotificationSettings { + const defaults = getDefaultNotificationSettings() + const candidate = + value && typeof value === 'object' ? (value as Partial) : {} + const rawSoundId = (candidate as { customSoundId?: unknown }).customSoundId + const customSoundId = + rawSoundId === 'system' || + rawSoundId === 'two-tone' || + rawSoundId === 'bong' || + rawSoundId === 'thump' || + rawSoundId === 'blip' || + rawSoundId === 'sonar' || + rawSoundId === 'blop' || + rawSoundId === 'ding' || + rawSoundId === 'clack' || + rawSoundId === 'beep' || + rawSoundId === 'custom' + ? rawSoundId + : rawSoundId === 'orca' || rawSoundId === 'chime' + ? 'two-tone' + : rawSoundId === 'pop' + ? 'blop' + : typeof candidate.customSoundPath === 'string' + ? 'custom' + : defaults.customSoundId + const rawVolume = candidate.customSoundVolume + const customSoundVolume = + typeof rawVolume === 'number' && Number.isFinite(rawVolume) + ? Math.min(100, Math.max(0, rawVolume)) + : defaults.customSoundVolume + return { + ...defaults, + ...candidate, + customSoundId, + customSoundVolume + } +} + +export type SanitizeOnboardingUpdateOptions = { + migrateLegacyProgress?: boolean +} + +export function remapLegacyOnboardingLastCompletedStep( + lastCompletedStep: number, + raw: Record +): number { + if (raw.outcome === 'completed' && lastCompletedStep >= 4) { + return ONBOARDING_FINAL_STEP + } + // Why: v3 (pre-Windows-terminal-page) step 4 already meant notifications, so resume there, not the inserted Windows step. + if (raw.flowVersion === 3) { + return Math.min(4, lastCompletedStep) + } + // Why: v2's five-step flow had step 4 = removed agent setup, not completed integrations. + if (raw.flowVersion === 2) { + if (lastCompletedStep === 3) { + return 2 + } + if (lastCompletedStep >= 4) { + return 3 + } + return lastCompletedStep + } + if (lastCompletedStep === 3) { + return 2 + } + if (lastCompletedStep === 4) { + return 2 + } + if (lastCompletedStep >= 5) { + return 3 + } + return lastCompletedStep +} + +export function sanitizeOnboardingUpdate( + input: unknown, + options: SanitizeOnboardingUpdateOptions = {} +): Partial> & { checklist?: Partial } { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return {} + } + const raw = input as Record + const out: Partial> & { + checklist?: Partial + } = {} + + if ('closedAt' in raw) { + // Why: NaN/Infinity serialize to null on save, reverting closedAt and reopening the wizard; require a finite timestamp. + 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 ('flowVersion' in raw) { + const v = raw.flowVersion + if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= ONBOARDING_FLOW_VERSION) { + out.flowVersion = v + } + // else: omit. + } + if ('lastCompletedStep' in raw) { + const v = raw.lastCompletedStep + if (typeof v === 'number' && Number.isInteger(v) && v >= -1) { + const isLegacyFlow = + options.migrateLegacyProgress && raw.flowVersion !== ONBOARDING_FLOW_VERSION + // Why: removing two wizard pages changed step numbering; migrate legacy values before the final-step bound drops them. + const normalized = isLegacyFlow ? remapLegacyOnboardingLastCompletedStep(v, raw) : v + if (normalized <= ONBOARDING_FINAL_STEP) { + out.lastCompletedStep = normalized + } + } + // 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 don't reset other checklist items to false. + const defaults = getDefaultOnboardingState().checklist + const rc = rawChecklist as Record + const checklist: Partial = {} + 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 + } + } + if (options.migrateLegacyProgress) { + out.flowVersion = ONBOARDING_FLOW_VERSION + } + return out +} + +export function normalizeLoadedOnboardingState( + input: unknown, + defaults: OnboardingState +): OnboardingState { + // Why: an existing file with no onboarding block is an upgrade user; backfill as completed so they skip the wizard. + if (!input) { + return { + ...defaults, + closedAt: Date.now(), + outcome: 'completed', + lastCompletedStep: ONBOARDING_FINAL_STEP + } + } + // Why: sanitize persisted onboarding keys so a type-flipped field on disk can't poison in-memory state. + const sanitized = sanitizeOnboardingUpdate(input, { + migrateLegacyProgress: true + }) + // Why: a completed/dismissed outcome means the user left; recover a bad closedAt instead of reopening the checklist. + const recoveredClosedAt = + typeof sanitized.closedAt === 'number' + ? sanitized.closedAt + : sanitized.outcome !== null && sanitized.outcome !== undefined + ? Date.now() + : sanitized.closedAt + return { + ...defaults, + ...sanitized, + closedAt: recoveredClosedAt ?? defaults.closedAt, + checklist: { + ...defaults.checklist, + ...sanitized.checklist + } + } +} + +export function resolveSetupGuideSidebarDismissedOnLoad( + persistedDismissed: unknown, + onboarding: OnboardingState +): boolean { + // Why: once onboarding is closed, persisted false is just the old default, not a user opt-in to the sidebar checklist. + return onboarding.closedAt !== null || persistedDismissed === true +} + +// Why: read a settings field removed from GlobalSettings but still on disk; one-shot for the inline-agents migration. +export function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { + return ( + (parsed?.settings as { experimentalAgentDashboard?: boolean } | undefined) + ?.experimentalAgentDashboard === true + ) +} + +export function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | undefined { + return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick +} diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts new file mode 100644 index 00000000000..33b652aee6d --- /dev/null +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -0,0 +1,247 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../shared/tui-agent-launch-defaults' +import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' +import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy' +import { normalizeTaskProviderSettings } from '../../../shared/task-providers' +import { normalizeOpenInApplications } from '../../../shared/open-in-applications' +import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' +import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' +import { normalizeAppIconId } from '../../../shared/app-icon' +import { normalizeUiLanguage } from '../../../shared/ui-language' +import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' +import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../shared/mobile-pairing-custom-address' +import { + mergeLegacyCommitMessageAiIntoSourceControlAi, + normalizeSourceControlAiSettings, + projectSourceControlAiToLegacyCommitMessageAi +} from '../../../shared/source-control-ai' +import { + PROTECTED_SECRET_SLOT, + type ProtectedSecretPersistence +} from '../../protected-secret-persistence' +import { normalizeNotificationSettings } from './onboarding-normalization' +import { retireLegacyInstructionsForClearedTextActionRecipes } from './source-control-settings' +import { + buildWorkspaceDirHistoryForUpdate, + stripRetiredGlobalSettings +} from './terminal-settings-migrations' + +export type SettingsMutationOperations = { + state: StoreOwnedPersistedState + removeRetainedBlob: ( + slot: Parameters[0] + ) => void + scheduleSave: () => void + notifySettingsChanged: (updates: Partial, originWebContentsId?: number) => void +} + +export function updateSettings( + operations: SettingsMutationOperations, + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} +): GlobalSettings { + const sanitizedUpdates = stripRetiredGlobalSettings(updates) + if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { + operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) + } + if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { + operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + } + // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". + if ('minimizeToTrayOnClose' in updates) { + sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true + } + if ('showMenuBarIcon' in updates) { + sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true + } + // Why: the artifact publish capability must be an exact boolean on disk; no truthy value grants it. + if ('artifactSharingEnabled' in updates) { + sanitizedUpdates.artifactSharingEnabled = updates.artifactSharingEnabled === true + } + if ('disabledTuiAgents' in updates) { + sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) + } + if ('worktreeVisibilityDefaults' in updates) { + sanitizedUpdates.worktreeVisibilityDefaults = { + ...operations.state.settings.worktreeVisibilityDefaults, + ...(normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) ?? { + external: 'hide' + }) + } + } + if ('agentDefaultArgs' in updates) { + sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) + sanitizedUpdates.agentYoloDefaultsMigrated = true + } + if ('agentDefaultEnv' in updates) { + sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) + sanitizedUpdates.agentYoloDefaultsMigrated = true + } + if ('terminalQuickCommands' in updates) { + sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands( + updates.terminalQuickCommands + ) + } + if ('terminalCustomThemes' in updates) { + sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes( + updates.terminalCustomThemes + ) + } + if ('terminalCursorStyle' in updates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: updates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } + if ('terminalScrollbackRows' in updates) { + sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( + updates.terminalScrollbackRows + ) + } + if ( + 'terminalTuiScrollSensitivity' in updates || + 'terminalTuiScrollSensitivityDefaultedToOne' in updates + ) { + sanitizedUpdates.terminalTuiScrollSensitivityDefaultedToOne = true + } + if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: + 'visibleTaskProviders' in updates + ? updates.visibleTaskProviders + : operations.state.settings.visibleTaskProviders, + defaultTaskSource: + 'defaultTaskSource' in updates + ? updates.defaultTaskSource + : operations.state.settings.defaultTaskSource + }) + sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource + sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders + if ('visibleTaskProviders' in updates) { + sanitizedUpdates.visibleTaskProvidersDefaultedForJira = true + } + } + if ('autoRenameBranchFromWork' in updates || 'autoRenameBranchFromWorkDefaultedOn' in updates) { + sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true + } + if ('openInApplications' in updates) { + sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) + } + if ('terminalShortcutPolicy' in updates) { + sanitizedUpdates.terminalShortcutPolicy = normalizeTerminalShortcutPolicy( + updates.terminalShortcutPolicy + ) + } + if ('sourceControlGroupOrder' in updates) { + sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( + updates.sourceControlGroupOrder + ) + } + if ('appIcon' in updates) { + sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) + } + if ('uiLanguage' in updates) { + sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) + } + if ('prBotAuthorOverrides' in updates) { + // Why: every writer (desktop IPC, web RPC, migrations) hits this boundary, so the persisted list stays bounded and well-formed. + sanitizedUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + updates.prBotAuthorOverrides + ) + } + if ('mobilePairingCustomAddress' in updates) { + sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + updates.mobilePairingCustomAddress + ) + } + if ('mobilePairingCustomAddresses' in updates) { + sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses( + updates.mobilePairingCustomAddresses + ) + } + if ( + 'mobilePairingCustomAddress' in sanitizedUpdates || + 'mobilePairingCustomAddresses' in sanitizedUpdates + ) { + const mobilePairingCustomAddress = + 'mobilePairingCustomAddress' in sanitizedUpdates + ? sanitizedUpdates.mobilePairingCustomAddress + : operations.state.settings.mobilePairingCustomAddress + if (mobilePairingCustomAddress) { + sanitizedUpdates.mobilePairingCustomAddresses = addMobilePairingCustomAddress( + sanitizedUpdates.mobilePairingCustomAddresses ?? + operations.state.settings.mobilePairingCustomAddresses ?? + [], + mobilePairingCustomAddress + ) + } + } + const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate( + operations.state.settings, + sanitizedUpdates + ) + if (historyWithPreviousLayout) { + sanitizedUpdates.workspaceDirHistory = historyWithPreviousLayout + } + // Why deep-merge telemetry: a partial update (e.g. flipping only `optedIn`) must not clobber siblings like `installId`. + const mergedTelemetry = + sanitizedUpdates.telemetry !== undefined + ? { ...operations.state.settings.telemetry, ...sanitizedUpdates.telemetry } + : operations.state.settings.telemetry + if ('sourceControlAi' in sanitizedUpdates) { + sanitizedUpdates.sourceControlAi = retireLegacyInstructionsForClearedTextActionRecipes( + sanitizedUpdates.sourceControlAi, + operations.state.settings + ) + const normalizedSourceControlAi = normalizeSourceControlAiSettings( + sanitizedUpdates.sourceControlAi, + operations.state.settings.commitMessageAi + ) + sanitizedUpdates.sourceControlAi = normalizedSourceControlAi + sanitizedUpdates.commitMessageAi = projectSourceControlAiToLegacyCommitMessageAi( + normalizedSourceControlAi, + operations.state.settings.commitMessageAi + ) + } else if ('commitMessageAi' in sanitizedUpdates) { + sanitizedUpdates.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi( + operations.state.settings.sourceControlAi, + sanitizedUpdates.commitMessageAi + ) + } + const previousSettings = operations.state.settings + operations.state.settings = { + ...operations.state.settings, + ...sanitizedUpdates, + notifications: normalizeNotificationSettings({ + ...operations.state.settings.notifications, + ...sanitizedUpdates.notifications + }), + ...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {}) + } + operations.scheduleSave() + const changedUpdates = {} as Partial & Record + for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) { + if (!Object.is(previousSettings[key], operations.state.settings[key])) { + changedUpdates[String(key)] = operations.state.settings[key] + } + } + if (options.notifyListeners === true && Object.keys(changedUpdates).length > 0) { + operations.notifySettingsChanged(changedUpdates, options.originWebContentsId) + } + return operations.state.settings +} diff --git a/src/main/persistence/applying-settings/source-control-settings.ts b/src/main/persistence/applying-settings/source-control-settings.ts new file mode 100644 index 00000000000..41b5164afec --- /dev/null +++ b/src/main/persistence/applying-settings/source-control-settings.ts @@ -0,0 +1,46 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { normalizeSourceControlAiSettings } from '../../../shared/source-control-ai' +import { + DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES, + SOURCE_CONTROL_TEXT_ACTION_IDS +} from '../../../shared/source-control-ai-actions' + +export function retireLegacyInstructionsForClearedTextActionRecipes( + sourceControlAi: GlobalSettings['sourceControlAi'], + previousSettings: GlobalSettings +): GlobalSettings['sourceControlAi'] { + if (!sourceControlAi?.actions) { + return sourceControlAi + } + + const previousSourceControlAi = normalizeSourceControlAiSettings( + previousSettings.sourceControlAi, + previousSettings.commitMessageAi + ) + let instructionsByOperation = sourceControlAi.instructionsByOperation + let changed = false + for (const actionId of SOURCE_CONTROL_TEXT_ACTION_IDS) { + if ( + sourceControlAi.actions[actionId]?.commandInputTemplate !== + DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] + ) { + continue + } + if ( + previousSourceControlAi.actions?.[actionId]?.commandInputTemplate === + DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] || + instructionsByOperation?.[actionId] !== + previousSourceControlAi.instructionsByOperation[actionId] + ) { + continue + } + if (instructionsByOperation?.[actionId] === '') { + continue + } + // Why: {basePrompt} is the explicit clear state; an empty instruction shadows rollback commitMessageAi.customPrompt on normalize/project. + instructionsByOperation = { ...instructionsByOperation, [actionId]: '' } + changed = true + } + + return changed ? { ...sourceControlAi, instructionsByOperation } : sourceControlAi +} diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts new file mode 100644 index 00000000000..6c04673ca07 --- /dev/null +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -0,0 +1,165 @@ +import type { GlobalSettings, OrcaWorkspaceLayout } from '../../../shared/global-settings-types' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { + legacyTerminalScrollbackBytesToRows, + normalizeDesktopTerminalScrollbackRows +} from '../../../shared/terminal-scrollback-policy' +import { + DEFAULT_TUI_AGENT_ARGS, + DEFAULT_TUI_AGENT_ENV, + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../shared/tui-agent-launch-defaults' + +export function buildWorkspaceDirHistoryForUpdate( + current: GlobalSettings, + updates: Partial +): OrcaWorkspaceLayout[] | null { + if (!('workspaceDir' in updates) && !('nestWorkspaces' in updates)) { + return null + } + const nextPath = updates.workspaceDir ?? current.workspaceDir + const nextNestWorkspaces = updates.nestWorkspaces ?? current.nestWorkspaces + if ( + normalizeRuntimePathForComparison(nextPath) === + normalizeRuntimePathForComparison(current.workspaceDir) && + nextNestWorkspaces === current.nestWorkspaces + ) { + return null + } + + const previousLayout = { + path: current.workspaceDir, + nestWorkspaces: current.nestWorkspaces + } + const existing = current.workspaceDirHistory ?? [] + const next = [...existing] + const previousKey = getWorkspaceLayoutHistoryKey(previousLayout) + if (!next.some((layout) => getWorkspaceLayoutHistoryKey(layout) === previousKey)) { + next.push(previousLayout) + } + return next +} + +export type LegacyTerminalScrollbackSettings = { + terminalScrollbackRows?: unknown + terminalScrollbackBytes?: unknown +} + +export const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3 + +export function readLegacyTerminalScrollbackSettings( + settings: unknown +): LegacyTerminalScrollbackSettings { + return settings && typeof settings === 'object' + ? (settings as LegacyTerminalScrollbackSettings) + : {} +} + +type RetiredGlobalSettings = { + terminalScrollbackBytes?: unknown + enableGitHubAttribution?: unknown +} + +export function stripRetiredGlobalSettings( + settings: Partial | undefined +): Partial { + const { + terminalScrollbackBytes: _legacyScrollbackBytes, + enableGitHubAttribution: _legacyGitHubAttribution, + ...rest + } = (settings ?? {}) as Partial & RetiredGlobalSettings + void _legacyScrollbackBytes + void _legacyGitHubAttribution + return rest +} + +export function migrateTerminalScrollbackRows(settings: unknown): { + rows: number + needsSave: boolean +} { + const legacySettings = readLegacyTerminalScrollbackSettings(settings) + const hasRows = Object.hasOwn(legacySettings, 'terminalScrollbackRows') + const hasLegacyBytes = Object.hasOwn(legacySettings, 'terminalScrollbackBytes') + const rows = hasRows + ? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows) + : legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes) + + return { + rows, + needsSave: !hasRows || hasLegacyBytes || legacySettings.terminalScrollbackRows !== rows + } +} + +export function migrateTerminalTuiScrollSensitivityDefault(settings: GlobalSettings | undefined): { + settings: Pick< + GlobalSettings, + 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' + > + needsSave: boolean +} { + const alreadyDefaultedToOne = settings?.terminalTuiScrollSensitivityDefaultedToOne === true + const current = settings?.terminalTuiScrollSensitivity + const shouldMoveInheritedDefault = + !alreadyDefaultedToOne && + (current === undefined || current === LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT) + const terminalTuiScrollSensitivity = shouldMoveInheritedDefault ? 1 : (current ?? 1) + + return { + settings: { + terminalTuiScrollSensitivity, + terminalTuiScrollSensitivityDefaultedToOne: true + }, + needsSave: !alreadyDefaultedToOne || current === undefined + } +} + +export function getWorkspaceLayoutHistoryKey(layout: OrcaWorkspaceLayout): string { + return `${normalizeRuntimePathForComparison(layout.path)}:${layout.nestWorkspaces}` +} + +export function migrateAgentYoloDefaults( + settings: GlobalSettings | undefined +): Pick { + const existingArgs = normalizeTuiAgentArgsRecord(settings?.agentDefaultArgs) + const existingEnv = normalizeTuiAgentEnvRecord(settings?.agentDefaultEnv) + if (settings?.agentYoloDefaultsMigrated === true) { + return { + agentDefaultArgs: existingArgs, + agentDefaultEnv: existingEnv, + agentYoloDefaultsMigrated: true + } + } + + const commandOverrides = settings?.agentCmdOverrides ?? {} + const migratedArgs = { ...existingArgs } + for (const [agent, args] of Object.entries(DEFAULT_TUI_AGENT_ARGS)) { + if (agent in migratedArgs) { + continue + } + if (agent in commandOverrides) { + migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = '' + continue + } + migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = args + } + + const migratedEnv = { ...existingEnv } + for (const [agent, env] of Object.entries(DEFAULT_TUI_AGENT_ENV)) { + if (agent in migratedEnv) { + continue + } + if (agent in commandOverrides) { + migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = {} + continue + } + migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = { ...env } + } + + return { + // Why: legacy users could only customize launch defaults via command overrides, so those agents count as already user-owned. + agentDefaultArgs: migratedArgs, + agentDefaultEnv: migratedEnv, + agentYoloDefaultsMigrated: true + } +} diff --git a/src/main/persistence/applying-settings/ui-interaction-merge.ts b/src/main/persistence/applying-settings/ui-interaction-merge.ts new file mode 100644 index 00000000000..dfd16c3d675 --- /dev/null +++ b/src/main/persistence/applying-settings/ui-interaction-merge.ts @@ -0,0 +1,102 @@ +import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceLineage } from '../../../shared/worktree/lineage-types' +import { normalizeFeatureInteractions } from '../../../shared/feature-interactions' +import { normalizeContextualTourIds } from '../../../shared/contextual-tours' +import { isWorkspaceKey } from '../../../shared/workspace-scope' + +export function mergeFeatureInteractions( + current: PersistedState['ui']['featureInteractions'], + incoming: PersistedState['ui']['featureInteractions'] +): PersistedState['ui']['featureInteractions'] { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const currentRecord = currentNormalized[id as keyof typeof currentNormalized] + merged[id as keyof typeof merged] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + +export function mergeContextualTourSeenIds( + current: PersistedState['ui']['contextualToursSeenIds'], + incoming: PersistedState['ui']['contextualToursSeenIds'] +): PersistedState['ui']['contextualToursSeenIds'] { + const merged = new Set(normalizeContextualTourIds(current)) + for (const id of normalizeContextualTourIds(incoming)) { + merged.add(id) + } + return [...merged] +} + +export function stripMainOwnedTelemetryMarkerFromUI( + value: Partial | undefined +): Partial { + if (!value || typeof value !== 'object') { + return {} + } + const { featureInteractionTelemetryBuckets: _reserved, ...ui } = value as Partial< + PersistedState['ui'] + > & { + featureInteractionTelemetryBuckets?: unknown + } + void _reserved + return ui +} + +export function normalizeWorkspaceLineageByChildKey( + value: unknown +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const normalized: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (!isWorkspaceKey(key) || !entry || typeof entry !== 'object') { + continue + } + const lineage = entry as Partial + const childWorkspaceKey = + typeof lineage.childWorkspaceKey === 'string' && isWorkspaceKey(lineage.childWorkspaceKey) + ? lineage.childWorkspaceKey + : key + const parentWorkspaceKey = lineage.parentWorkspaceKey + if ( + !isWorkspaceKey(childWorkspaceKey) || + typeof parentWorkspaceKey !== 'string' || + !isWorkspaceKey(parentWorkspaceKey) || + childWorkspaceKey !== key || + childWorkspaceKey === parentWorkspaceKey + ) { + continue + } + normalized[childWorkspaceKey] = { + childWorkspaceKey, + childInstanceId: lineage.childInstanceId ?? null, + parentWorkspaceKey, + parentInstanceId: lineage.parentInstanceId ?? null, + origin: lineage.origin ?? 'cli', + capture: lineage.capture ?? { source: 'manual-action', confidence: 'inferred' }, + ...(lineage.taskId ? { taskId: lineage.taskId } : {}), + ...(lineage.orchestrationRunId ? { orchestrationRunId: lineage.orchestrationRunId } : {}), + ...(lineage.coordinatorHandle ? { coordinatorHandle: lineage.coordinatorHandle } : {}), + ...(lineage.createdByTerminalHandle + ? { createdByTerminalHandle: lineage.createdByTerminalHandle } + : {}), + createdAt: Number.isFinite(lineage.createdAt) ? Number(lineage.createdAt) : Date.now() + } + } + return normalized +} diff --git a/src/main/persistence/applying-settings/ui-selection-normalization.ts b/src/main/persistence/applying-settings/ui-selection-normalization.ts new file mode 100644 index 00000000000..644d0d4a8e7 --- /dev/null +++ b/src/main/persistence/applying-settings/ui-selection-normalization.ts @@ -0,0 +1,95 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultUIState } from '../../../shared/constants' +import { isPluginPanelTabKey } from '../../../shared/plugins/plugin-manifest' + +export 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 +} + +export function normalizeShowDotfilesByWorktree(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const out: Record = {} + for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { + if ( + !worktreeId || + worktreeId === '__proto__' || + worktreeId === 'constructor' || + worktreeId === 'prototype' || + typeof showDotfiles !== 'boolean' + ) { + continue + } + out[worktreeId] = showDotfiles + } + return out +} + +export function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { + if ( + sortBy === 'smart' || + sortBy === 'recent' || + sortBy === 'repo' || + sortBy === 'name' || + sortBy === 'manual' + ) { + return sortBy + } + return getDefaultUIState().sortBy +} + +export function normalizeProjectOrderBy( + projectOrderBy: unknown +): PersistedState['ui']['projectOrderBy'] { + if (projectOrderBy === 'manual' || projectOrderBy === 'recent') { + return projectOrderBy + } + return getDefaultUIState().projectOrderBy +} + +export function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { + if ( + tab === 'explorer' || + tab === 'search' || + tab === 'vault' || + tab === 'workspaces' || + tab === 'pr-checks' || + tab === 'source-control' || + tab === 'checks' || + tab === 'ports' + ) { + return tab + } + // Why: plugin tabs are open-ended `plugin:./` keys; validate the + // shape so a persisted plugin tab doesn't reset to Explorer on restart. + if (typeof tab === 'string' && isPluginPanelTabKey(tab)) { + return tab + } + return getDefaultUIState().rightSidebarTab +} + +export function normalizeRightSidebarExplorerView( + view: unknown, + tab?: unknown +): PersistedState['ui']['rightSidebarExplorerView'] { + // Why: older builds persisted Search as a standalone activity tab. + if (tab === 'search') { + return 'search' + } + if (view === 'files' || view === 'search') { + return view + } + return getDefaultUIState().rightSidebarExplorerView +} diff --git a/src/main/persistence/applying-settings/ui-state-read.ts b/src/main/persistence/applying-settings/ui-state-read.ts new file mode 100644 index 00000000000..7a249934260 --- /dev/null +++ b/src/main/persistence/applying-settings/ui-state-read.ts @@ -0,0 +1,74 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { + getDefaultUIState, + normalizeAgentActivityDisplayMode, + normalizeWorktreeCardProperties +} from '../../../shared/constants' +import { + normalizeWorkspaceStatuses, + clampWorkspaceBoardColumnWidth, + clampWorkspaceBoardOpacity +} from '../../../shared/workspace-statuses' +import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' +import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' +import { clampMarkdownTocPanelWidth } from '../../../shared/markdown-toc-panel-width' +import { clampCombinedDiffFileTreeWidth } from '../../../shared/combined-diff-file-tree-width' +import { + normalizeVisibleExecutionHostIds, + normalizeExecutionHostOrder +} from '../../../shared/execution-host' +import { normalizeManualRepoOrder } from '../../../shared/manual-repo-order' +import { normalizeBrowserPageZoomLevel } from '../../../shared/browser-page-zoom' +import { normalizeFeatureTipIds } from '../../../shared/feature-tips' +import { normalizeContextualTourIds } from '../../../shared/contextual-tours' +import { normalizeFeatureInteractions } from '../../../shared/feature-interactions' +import { + normalizeGroupBy, + normalizeProjectOrderBy, + normalizeRightSidebarExplorerView, + normalizeRightSidebarTab, + normalizeShowDotfilesByWorktree, + normalizeSortBy +} from './ui-selection-normalization' +import { stripMainOwnedTelemetryMarkerFromUI } from './ui-interaction-merge' + +export function getPersistedUI( + state: PersistedState, + activeView: PersistedState['ui']['activeView'] +): PersistedState['ui'] { + const uiState = stripMainOwnedTelemetryMarkerFromUI(state.ui) + return { + ...getDefaultUIState(), + ...uiState, + groupBy: normalizeGroupBy(state.ui?.groupBy), + sortBy: normalizeSortBy(state.ui?.sortBy), + projectOrderBy: normalizeProjectOrderBy(state.ui?.projectOrderBy), + rightSidebarTab: normalizeRightSidebarTab(state.ui?.rightSidebarTab), + rightSidebarExplorerView: normalizeRightSidebarExplorerView( + state.ui?.rightSidebarExplorerView, + state.ui?.rightSidebarTab + ), + worktreeCardProperties: normalizeWorktreeCardProperties(state.ui?.worktreeCardProperties), + agentActivityDisplayMode: normalizeAgentActivityDisplayMode(state.ui?.agentActivityDisplayMode), + workspaceStatuses: normalizeWorkspaceStatuses(state.ui?.workspaceStatuses), + workspaceBoardOpacity: clampWorkspaceBoardOpacity(state.ui?.workspaceBoardOpacity), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(state.ui?.workspaceBoardColumnWidth), + syncTaskStatusFromWorkspaceBoard: state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay(state.ui?.usagePercentageDisplay), + statusBarUsageMode: normalizeStatusBarUsageMode(state.ui?.statusBarUsageMode), + // Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires). + trayMinimizeNoticeShown: state.ui?.trayMinimizeNoticeShown === true, + osc52ClipboardDefaultOnNoticePending: state.ui?.osc52ClipboardDefaultOnNoticePending === true, + markdownTocPanelWidth: clampMarkdownTocPanelWidth(state.ui?.markdownTocPanelWidth), + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth(state.ui?.combinedDiffFileTreeWidth), + visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(state.ui?.visibleWorkspaceHostIds), + workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder), + manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder), + browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel), + showDotfilesByWorktree: normalizeShowDotfilesByWorktree(state.ui?.showDotfilesByWorktree), + featureTipsSeenIds: normalizeFeatureTipIds(state.ui?.featureTipsSeenIds), + contextualToursSeenIds: normalizeContextualTourIds(state.ui?.contextualToursSeenIds), + featureInteractions: normalizeFeatureInteractions(state.ui?.featureInteractions), + activeView: activeView + } +} diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts new file mode 100644 index 00000000000..f757fa5a1c4 --- /dev/null +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -0,0 +1,201 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { + getDefaultUIState, + normalizeAgentActivityDisplayMode, + normalizeWorktreeCardProperties +} from '../../../shared/constants' +import { + normalizeWorkspaceStatuses, + clampWorkspaceBoardColumnWidth, + clampWorkspaceBoardOpacity +} from '../../../shared/workspace-statuses' +import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' +import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' +import { clampMarkdownTocPanelWidth } from '../../../shared/markdown-toc-panel-width' +import { clampCombinedDiffFileTreeWidth } from '../../../shared/combined-diff-file-tree-width' +import { + normalizeVisibleExecutionHostIds, + normalizeExecutionHostOrder +} from '../../../shared/execution-host' +import { normalizeManualRepoOrder } from '../../../shared/manual-repo-order' +import { normalizeBrowserPageZoomLevel } from '../../../shared/browser-page-zoom' +import { normalizeFeatureTipIds } from '../../../shared/feature-tips' +import { normalizeContextualTourIds } from '../../../shared/contextual-tours' +import { normalizeFeatureInteractions } from '../../../shared/feature-interactions' +import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' +import { persistedUIValuesEqual } from '../../../shared/persisted-ui-equality' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { + PROTECTED_SECRET_SLOT, + type ProtectedSecretPersistence +} from '../../protected-secret-persistence' +import { + normalizeGroupBy, + normalizeProjectOrderBy, + normalizeRightSidebarExplorerView, + normalizeRightSidebarTab, + normalizeShowDotfilesByWorktree, + normalizeSortBy +} from './ui-selection-normalization' +import { + mergeContextualTourSeenIds, + mergeFeatureInteractions, + stripMainOwnedTelemetryMarkerFromUI +} from './ui-interaction-merge' + +export type UIUpdateOperations = { + state: StoreOwnedPersistedState + removeRetainedBlob: ( + slot: Parameters[0] + ) => void + setActiveView: (activeView: PersistedState['ui']['activeView'] | undefined) => boolean + getUI: () => PersistedState['ui'] + scheduleSave: () => void + notifyUIChanged: () => void +} + +export function updatePersistedUI( + operations: UIUpdateOperations, + updates: Partial +): void { + if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { + operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) + } + const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) + const { activeView, ...durableUpdates } = sanitizedUpdates + const activeViewChanged = operations.setActiveView(activeView) + if (Object.keys(durableUpdates).length === 0) { + if (activeViewChanged) { + operations.notifyUIChanged() + } + return + } + const currentUI = { + ...getDefaultUIState(), + ...stripMainOwnedTelemetryMarkerFromUI(operations.state.ui) + } + const previousUI = { + ...operations.getUI(), + // Why: the legacy field stays unchanged as a migration/downgrade + // fallback; the profile sidecar is authoritative in current builds. + activeView: currentUI.activeView + } + const nextRightSidebarTab = + sanitizedUpdates.rightSidebarTab !== undefined + ? normalizeRightSidebarTab(sanitizedUpdates.rightSidebarTab) + : normalizeRightSidebarTab(operations.state.ui?.rightSidebarTab) + const nextRightSidebarExplorerView = + sanitizedUpdates.rightSidebarExplorerView !== undefined + ? normalizeRightSidebarExplorerView( + sanitizedUpdates.rightSidebarExplorerView, + nextRightSidebarTab + ) + : sanitizedUpdates.rightSidebarTab === 'search' + ? 'search' + : normalizeRightSidebarExplorerView( + operations.state.ui?.rightSidebarExplorerView, + nextRightSidebarTab + ) + const nextUI = { + ...currentUI, + ...durableUpdates, + workspaceCleanup: mergeWorkspaceCleanupUIState( + currentUI.workspaceCleanup, + durableUpdates.workspaceCleanup + ), + groupBy: durableUpdates.groupBy + ? normalizeGroupBy(durableUpdates.groupBy) + : normalizeGroupBy(operations.state.ui?.groupBy), + sortBy: durableUpdates.sortBy + ? normalizeSortBy(durableUpdates.sortBy) + : normalizeSortBy(operations.state.ui?.sortBy), + projectOrderBy: sanitizedUpdates.projectOrderBy + ? normalizeProjectOrderBy(sanitizedUpdates.projectOrderBy) + : normalizeProjectOrderBy(operations.state.ui?.projectOrderBy), + activeView: currentUI.activeView, + rightSidebarTab: nextRightSidebarTab, + rightSidebarExplorerView: nextRightSidebarExplorerView, + worktreeCardProperties: + sanitizedUpdates.worktreeCardProperties !== undefined + ? normalizeWorktreeCardProperties(sanitizedUpdates.worktreeCardProperties) + : normalizeWorktreeCardProperties(operations.state.ui?.worktreeCardProperties), + agentActivityDisplayMode: + sanitizedUpdates.agentActivityDisplayMode !== undefined + ? normalizeAgentActivityDisplayMode(sanitizedUpdates.agentActivityDisplayMode) + : normalizeAgentActivityDisplayMode(operations.state.ui?.agentActivityDisplayMode), + workspaceStatuses: + sanitizedUpdates.workspaceStatuses !== undefined + ? normalizeWorkspaceStatuses(sanitizedUpdates.workspaceStatuses) + : normalizeWorkspaceStatuses(operations.state.ui?.workspaceStatuses), + workspaceBoardOpacity: clampWorkspaceBoardOpacity( + sanitizedUpdates.workspaceBoardOpacity ?? operations.state.ui?.workspaceBoardOpacity + ), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( + sanitizedUpdates.workspaceBoardColumnWidth ?? operations.state.ui?.workspaceBoardColumnWidth + ), + syncTaskStatusFromWorkspaceBoard: + sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined + ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true + : operations.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + sanitizedUpdates.usagePercentageDisplay ?? operations.state.ui?.usagePercentageDisplay + ), + statusBarUsageMode: normalizeStatusBarUsageMode( + sanitizedUpdates.statusBarUsageMode ?? operations.state.ui?.statusBarUsageMode + ), + markdownTocPanelWidth: clampMarkdownTocPanelWidth( + sanitizedUpdates.markdownTocPanelWidth ?? operations.state.ui?.markdownTocPanelWidth + ), + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( + sanitizedUpdates.combinedDiffFileTreeWidth ?? operations.state.ui?.combinedDiffFileTreeWidth + ), + visibleWorkspaceHostIds: + sanitizedUpdates.visibleWorkspaceHostIds !== undefined + ? normalizeVisibleExecutionHostIds(sanitizedUpdates.visibleWorkspaceHostIds) + : normalizeVisibleExecutionHostIds(operations.state.ui?.visibleWorkspaceHostIds), + workspaceHostOrder: + sanitizedUpdates.workspaceHostOrder !== undefined + ? normalizeExecutionHostOrder(sanitizedUpdates.workspaceHostOrder) + : normalizeExecutionHostOrder(operations.state.ui?.workspaceHostOrder), + manualRepoOrder: + sanitizedUpdates.manualRepoOrder !== undefined + ? normalizeManualRepoOrder(sanitizedUpdates.manualRepoOrder) + : normalizeManualRepoOrder(operations.state.ui?.manualRepoOrder), + browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( + sanitizedUpdates.browserDefaultZoomLevel ?? operations.state.ui?.browserDefaultZoomLevel + ), + showDotfilesByWorktree: + sanitizedUpdates.showDotfilesByWorktree !== undefined + ? normalizeShowDotfilesByWorktree(sanitizedUpdates.showDotfilesByWorktree) + : normalizeShowDotfilesByWorktree(operations.state.ui?.showDotfilesByWorktree), + featureTipsSeenIds: + sanitizedUpdates.featureTipsSeenIds !== undefined + ? normalizeFeatureTipIds(sanitizedUpdates.featureTipsSeenIds) + : normalizeFeatureTipIds(operations.state.ui?.featureTipsSeenIds), + // Why: renderer and paired clients can mark different tours seen from stale snapshots; union so completed tours stay suppressed. + contextualToursSeenIds: + sanitizedUpdates.contextualToursSeenIds !== undefined + ? mergeContextualTourSeenIds( + operations.state.ui?.contextualToursSeenIds, + sanitizedUpdates.contextualToursSeenIds + ) + : normalizeContextualTourIds(operations.state.ui?.contextualToursSeenIds), + // Why: runtime RPCs and the renderer both record education state; merge so a stale renderer snapshot can't erase runtime-only interactions. + featureInteractions: + sanitizedUpdates.featureInteractions !== undefined + ? mergeFeatureInteractions( + operations.state.ui?.featureInteractions, + sanitizedUpdates.featureInteractions + ) + : normalizeFeatureInteractions(operations.state.ui?.featureInteractions) + } + if (persistedUIValuesEqual(previousUI, nextUI)) { + if (activeViewChanged) { + operations.notifyUIChanged() + } + return + } + operations.state.ui = nextUI + operations.scheduleSave() + operations.notifyUIChanged() +} diff --git a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts new file mode 100644 index 00000000000..33303b660c7 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts @@ -0,0 +1,11 @@ +export function isLegacyOpenCodeSessionCookie(value: string): boolean { + const trimmed = value.trim() + return ( + trimmed.startsWith('Fe26.2**') || + trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim())) + ) +} + +export function isLegacySshPtyOwnerLease(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts new file mode 100644 index 00000000000..c51d09f0e70 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -0,0 +1,126 @@ +import type { + SshPtyConsumerRecovery, + SshRemotePtyLease, + SshTarget +} from '../../../shared/ssh-types' +import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../shared/ssh-types' + +export type LegacySshTarget = SshTarget & { + remoteWorkspaceSyncEnabled?: unknown + remoteWorkspaceSyncGracePeriodSeconds?: unknown + experimentalPtySourceCreditV1?: unknown +} + +// Why: old targets predate configHost; default to label-based lookup so imported SSH aliases still resolve via ssh -G. +export function normalizeSshTarget(t: SshTarget): SshTarget { + const target = { ...(t as LegacySshTarget) } + const legacySyncEnabled = target.remoteWorkspaceSyncEnabled + const currentGracePeriodSeconds = target.relayGracePeriodSeconds + const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds + const systemSshConnectionReuse = target.systemSshConnectionReuse + // Why: remote sync now follows the SSH relay lifecycle, so retired per-target sync/grace fields are dropped at disk load. + delete target.remoteWorkspaceSyncEnabled + delete target.remoteWorkspaceSyncGracePeriodSeconds + delete target.relayGracePeriodSeconds + delete target.systemSshConnectionReuse + delete target.experimentalPtySourceCreditV1 + // Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration. + const relayGracePeriodSeconds = + legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number' + ? legacyGracePeriodSeconds + : currentGracePeriodSeconds + const normalized: SshTarget = { + ...target, + configHost: target.configHost ?? target.label ?? target.host + } + // Why: old SSH form persisted 10800 even without a user choice; treat that legacy default as the new implicit default. + if ( + relayGracePeriodSeconds !== undefined && + relayGracePeriodSeconds !== LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + ) { + normalized.relayGracePeriodSeconds = relayGracePeriodSeconds + } + if (systemSshConnectionReuse === false) { + normalized.systemSshConnectionReuse = false + } + return normalized +} + +// Why: strict whitelist — a record missing or mistyping a required field is dropped rather than partially trusted. +export function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Partial + 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 } : {}) + } +} + +export const SSH_PTY_OWNER_LEASE_MAX_LENGTH = 512 +export const ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH = 4096 + +export function normalizeSshPtyConsumerRecovery( + value: unknown, + ownerLeaseMaxLength = SSH_PTY_OWNER_LEASE_MAX_LENGTH +): SshPtyConsumerRecovery | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Partial + const clientGeneration = raw.clientGeneration + const ownerGeneration = raw.ownerGeneration + if ( + typeof raw.targetId !== 'string' || + raw.targetId.length === 0 || + raw.targetId.length > 512 || + typeof raw.clientInstanceId !== 'string' || + raw.clientInstanceId.length === 0 || + raw.clientInstanceId.length > 512 || + typeof raw.serverBuildId !== 'string' || + raw.serverBuildId.length === 0 || + raw.serverBuildId.length > 512 || + typeof clientGeneration !== 'number' || + !Number.isSafeInteger(clientGeneration) || + clientGeneration <= 0 || + typeof ownerGeneration !== 'number' || + !Number.isSafeInteger(ownerGeneration) || + ownerGeneration <= 0 || + typeof raw.ownerLease !== 'string' || + raw.ownerLease.length === 0 || + raw.ownerLease.length > ownerLeaseMaxLength + ) { + return null + } + const flow = raw.outputFlowControl + const outputFlowControl = + flow?.version === 1 && Number.isSafeInteger(flow.windowSu) && flow.windowSu > 0 + ? { version: 1 as const, windowSu: flow.windowSu } + : undefined + return { + targetId: raw.targetId, + clientInstanceId: raw.clientInstanceId, + serverBuildId: raw.serverBuildId, + clientGeneration, + ownerGeneration, + ownerLease: raw.ownerLease, + ...(outputFlowControl ? { outputFlowControl } : {}) + } +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts new file mode 100644 index 00000000000..4a094ddb242 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts @@ -0,0 +1,117 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' + +export type SshPtyBindingCleanupOperations = { + state: StoreOwnedPersistedState + toComparablePtyId: (targetId: string, ptyId: string) => string + scheduleSave: () => void +} + +function sshRemotePtyLeaseMayReferenceBinding( + operations: SshPtyBindingCleanupOperations, + lease: SshRemotePtyLease, + binding: { + ptyId: string + targetId: string + worktreeId?: string + tabId?: string + leafId?: string + } +): boolean { + const bindingPtyId = operations.toComparablePtyId(binding.targetId, binding.ptyId) + if (lease.targetId !== binding.targetId || lease.ptyId !== bindingPtyId) { + return false + } + // Why: target removal is destructive; scrub matching bindings before deleting the lease, else removing the tombstone can revive stale PTY ids. + 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) + ) +} + +export function clearSshRemotePtyBindingsForTarget( + operations: SshPtyBindingCleanupOperations, + targetId: string +): void { + const leases = operations.state.sshRemotePtyLeases?.filter((lease) => lease.targetId === targetId) + clearSshRemotePtyBindingsForLeases(operations, targetId, leases ?? []) +} + +export function clearSshRemotePtyBindingsForLeases( + operations: SshPtyBindingCleanupOperations, + targetId: string, + leases: SshRemotePtyLease[] +): boolean { + if (!leases?.length) { + return false + } + let changed = false + const sessions = new Set( + [ + operations.state.workspaceSession, + operations.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)] + ].filter((session): session is WorkspaceSessionState => Boolean(session)) + ) + for (const session of sessions) { + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if ( + tab.ptyId && + leases.some((lease) => + sshRemotePtyLeaseMayReferenceBinding(operations, lease, { + ptyId: tab.ptyId!, + worktreeId, + targetId, + tabId: tab.id + }) + ) + ) { + tab.ptyId = null + changed = true + } + } + } + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (!worktreeIdByTabId.has(tab.id)) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { + const bindings = layout.ptyIdsByLeafId + if (!bindings) { + continue + } + const worktreeId = worktreeIdByTabId.get(tabId) + const nextBindings = Object.fromEntries( + Object.entries(bindings).filter( + ([leafId, ptyId]) => + !leases.some((lease) => + sshRemotePtyLeaseMayReferenceBinding(operations, lease, { + ptyId, + targetId, + worktreeId, + tabId, + leafId + }) + ) + ) + ) + if (Object.keys(nextBindings).length !== Object.keys(bindings).length) { + layout.ptyIdsByLeafId = nextBindings + changed = true + } + } + } + if (changed) { + operations.scheduleSave() + } + return changed +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts new file mode 100644 index 00000000000..f7c8f07f24a --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -0,0 +1,69 @@ +import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' +import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' + +export type SshPtyConsumerRecoveryOperations = { + state: StoreOwnedPersistedState + protectedSecrets: Pick + flushDurableStateOrThrowAsync: () => Promise +} + +async function flushSshPtyConsumerRecovery( + operations: SshPtyConsumerRecoveryOperations +): Promise { + // Why: ownership must be durable before relay setup continues, but this runs on the live + // establish/reconnect path — a sync flush would park the main thread on a stalled profile mount. + // Why not caught here: the failure must reach the awaiting caller. + await operations.flushDurableStateOrThrowAsync() +} + +export function getSshPtyConsumerRecovery( + operations: SshPtyConsumerRecoveryOperations, + targetId: string +): SshPtyConsumerRecovery | null { + const record = (operations.state.sshPtyConsumerRecoveries ?? []).find( + (candidate) => candidate.targetId === targetId + ) + if ( + record && + operations.protectedSecrets.isSealed( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) + ) { + return null + } + return record ? structuredClone(record) : null +} + +export async function upsertSshPtyConsumerRecovery( + operations: SshPtyConsumerRecoveryOperations, + record: SshPtyConsumerRecovery +): Promise { + const normalized = normalizeSshPtyConsumerRecovery(record) + if (!normalized) { + throw new Error('Invalid SSH PTY consumer recovery record') + } + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + operations.state.sshPtyConsumerRecoveries = [ + ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), + normalized + ] + await flushSshPtyConsumerRecovery(operations) +} + +export async function removeSshPtyConsumerRecovery( + operations: SshPtyConsumerRecoveryOperations, + targetId: string +): Promise { + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + const next = recoveries.filter((record) => record.targetId !== targetId) + if (next.length === recoveries.length) { + return + } + operations.state.sshPtyConsumerRecoveries = next + operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) + await flushSshPtyConsumerRecovery(operations) +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts new file mode 100644 index 00000000000..0acc77f7fcc --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -0,0 +1,204 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' + +export type SshPtyLeaseOperations = { + state: StoreOwnedPersistedState + toStoredPtyId: (targetId: string, ptyId: string) => string + clearBindingsForTarget: (targetId: string) => void + clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean + flush: () => void + flushDurableStateOrThrowAsync: () => Promise +} + +export function getSshRemotePtyLeases( + state: PersistedState, + targetId?: string +): SshRemotePtyLease[] { + const leases = state.sshRemotePtyLeases ?? [] + return leases.filter((lease) => targetId === undefined || lease.targetId === targetId) +} + +export function upsertSshRemotePtyLease( + operations: SshPtyLeaseOperations, + lease: Omit & + Partial> +): void { + operations.state.sshRemotePtyLeases ??= [] + const normalizedLease = { ...lease } + if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) { + delete normalizedLease.leafId + } + // Why: store target-local pty ids in leases so reconnect can call relay pty.attach with raw ids (app ids are global). + normalizedLease.ptyId = operations.toStoredPtyId(normalizedLease.targetId, normalizedLease.ptyId) + const now = Date.now() + const existingIndex = operations.state.sshRemotePtyLeases.findIndex( + (entry) => entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId + ) + const existing = + existingIndex !== -1 ? operations.state.sshRemotePtyLeases[existingIndex] : undefined + const next: SshRemotePtyLease = { + ...existing, + ...normalizedLease, + createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, + updatedAt: normalizedLease.updatedAt ?? now + } + if (existingIndex !== -1) { + operations.state.sshRemotePtyLeases[existingIndex] = next + } else { + operations.state.sshRemotePtyLeases.push(next) + } + operations.flush() +} + +function updateSshRemotePtyLeaseStates( + operations: SshPtyLeaseOperations, + targetId: string, + state: SshRemotePtyLease['state'], + ptyIds?: ReadonlySet +): boolean { + const now = Date.now() + let changed = false + const shouldClearBindings = state === 'terminated' || state === 'expired' + const leasesToClear: SshRemotePtyLease[] = [] + operations.state.sshRemotePtyLeases ??= [] + for (const lease of operations.state.sshRemotePtyLeases) { + if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) { + continue + } + if (state === 'attached' && (lease.state === 'terminated' || lease.state === 'expired')) { + continue + } + if (state === 'detached' && lease.state !== 'attached') { + continue + } + if (lease.state !== state) { + lease.state = state + lease.updatedAt = now + if (state === 'attached') { + lease.lastAttachedAt = now + } else if (state === 'detached') { + lease.lastDetachedAt = now + } + changed = true + } + if (shouldClearBindings) { + leasesToClear.push(lease) + } + } + const bindingsChanged = shouldClearBindings + ? operations.clearBindingsForLeases(targetId, leasesToClear) + : false + return changed || bindingsChanged +} + +export function markSshRemotePtyLeases( + operations: SshPtyLeaseOperations, + targetId: string, + state: SshRemotePtyLease['state'] +): void { + if (updateSshRemotePtyLeaseStates(operations, targetId, state)) { + operations.flush() + } +} + +// Why no write of its own: the committed quit path calls this immediately before the final store +// flush, and that flush is what persists it. A durable write here would race the flush and be +// rejected the moment it latches, which is exactly how an attached lease used to survive quit. +export function markSshRemotePtyLeasesForShutdown( + operations: SshPtyLeaseOperations, + targetId: string, + state: SshRemotePtyLease['state'] +): void { + updateSshRemotePtyLeaseStates(operations, targetId, state) +} + +export async function markSshRemotePtyLeasesAsync( + operations: SshPtyLeaseOperations, + targetId: string, + state: SshRemotePtyLease['state'] +): Promise { + if (updateSshRemotePtyLeaseStates(operations, targetId, state)) { + await operations.flushDurableStateOrThrowAsync() + } +} + +export async function markSshRemotePtyLeasesAttachedAsync( + operations: SshPtyLeaseOperations, + targetId: string, + ptyIds: readonly string[] +): Promise { + const relayPtyIds = new Set(ptyIds.map((ptyId) => operations.toStoredPtyId(targetId, ptyId))) + if (updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds)) { + await operations.flushDurableStateOrThrowAsync() + } +} + +export function markSshRemotePtyLease( + operations: SshPtyLeaseOperations, + targetId: string, + ptyId: string, + state: SshRemotePtyLease['state'] +): void { + const relayPtyId = operations.toStoredPtyId(targetId, ptyId) + const lease = operations.state.sshRemotePtyLeases?.find( + (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId + ) + if (!lease) { + return + } + const shouldClearBindings = state === 'terminated' || state === 'expired' + if (lease.state === state) { + if (shouldClearBindings && operations.clearBindingsForLeases(targetId, [lease])) { + operations.flush() + } + return + } + const now = Date.now() + lease.state = state + lease.updatedAt = now + if (state === 'attached') { + lease.lastAttachedAt = now + } else if (state === 'detached') { + lease.lastDetachedAt = now + } + if (shouldClearBindings) { + operations.clearBindingsForLeases(targetId, [lease]) + } + operations.flush() +} + +export function removeSshRemotePtyLease( + operations: SshPtyLeaseOperations, + targetId: string, + ptyId: string +): void { + const relayPtyId = operations.toStoredPtyId(targetId, ptyId) + const leases = (operations.state.sshRemotePtyLeases ?? []).filter( + (lease) => lease.targetId === targetId && lease.ptyId === relayPtyId + ) + const before = operations.state.sshRemotePtyLeases?.length ?? 0 + operations.clearBindingsForLeases(targetId, leases) + operations.state.sshRemotePtyLeases = (operations.state.sshRemotePtyLeases ?? []).filter( + (lease) => lease.targetId !== targetId || lease.ptyId !== relayPtyId + ) + if (operations.state.sshRemotePtyLeases.length !== before) { + operations.flush() + } +} + +export function removeSshRemotePtyLeases( + operations: SshPtyLeaseOperations, + targetId: string +): void { + operations.state.sshRemotePtyLeases ??= [] + operations.clearBindingsForTarget(targetId) + const before = operations.state.sshRemotePtyLeases.length + operations.state.sshRemotePtyLeases = operations.state.sshRemotePtyLeases.filter( + (lease) => lease.targetId !== targetId + ) + if (operations.state.sshRemotePtyLeases.length !== before) { + operations.flush() + } +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts new file mode 100644 index 00000000000..59e52c1b153 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -0,0 +1,123 @@ +import type { ProjectHostSetup } from '../../../shared/project-types' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { + migrateUiHostScopeSshTargetId, + migrateWorkspaceSessionSshTargetId +} from '../../ssh/ssh-target-id-migration' +import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' + +export type SshTargetReassignmentOperations = { + state: StoreOwnedPersistedState + protectedSecrets: Pick + syncProjectHostSetupCompatibilityState: () => void + scheduleSave: () => void +} + +/** + * Re-point every repo and worktree meta pinned to a removed SSH target id onto + * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. + */ +export function reassignSshTargetId( + operations: SshTargetReassignmentOperations, + oldTargetId: string, + newTargetId: string +): string[] { + if (oldTargetId === newTargetId) { + return [] + } + const oldHostId = toSshExecutionHostId(oldTargetId) + const newHostId = toSshExecutionHostId(newTargetId) + const repoIds = new Set() + for (const repo of operations.state.repos) { + const matchesConnection = repo.connectionId === oldTargetId + const matchesHost = repo.executionHostId === oldHostId + if (!matchesConnection && !matchesHost) { + continue + } + if (matchesConnection) { + repo.connectionId = newTargetId + } + // Why: don't stamp executionHostId where it was unset — addRemoteRepoFromPath repos derive the host from connectionId. + if (matchesHost) { + repo.executionHostId = newHostId + } + repoIds.add(repo.id) + } + // Re-point worktree metas whose hostId pointed at the old SSH host. + let metaChanged = false + for (const meta of Object.values(operations.state.worktreeMeta)) { + if (meta.hostId === oldHostId) { + meta.hostId = newHostId + metaChanged = true + } + } + // Why: any carrier still holding the old id later throws `SSH target not found` (STA-1468); migrate them all. + let carrierChanged = migrateWorkspaceSessionSshTargetId( + operations.state.workspaceSession, + oldTargetId, + newTargetId + ) + for (const session of Object.values(operations.state.workspaceSessionsByHostId ?? {})) { + if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) { + carrierChanged = true + } + } + // Why: partitions are read by host id; re-key from the removed id to the new one (keep new if it already exists). + const partitions = operations.state.workspaceSessionsByHostId + const oldPartition = partitions?.[oldHostId] + if (partitions && oldPartition) { + delete partitions[oldHostId] + partitions[newHostId] ??= oldPartition + carrierChanged = true + } + if (migrateUiHostScopeSshTargetId(operations.state.ui, oldTargetId, newTargetId)) { + carrierChanged = true + } + for (const lease of operations.state.sshRemotePtyLeases ?? []) { + if (lease.targetId === oldTargetId) { + lease.targetId = newTargetId + carrierChanged = true + } + } + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId) + if (retainedRecoveries.length !== recoveries.length) { + operations.state.sshPtyConsumerRecoveries = retainedRecoveries + operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId)) + carrierChanged = true + } + let setupsChanged = false + const keptSetups: ProjectHostSetup[] = [] + for (const setup of operations.state.projectHostSetups) { + if (setup.hostId !== oldHostId) { + keptSetups.push(setup) + continue + } + const duplicate = operations.state.projectHostSetups.some( + (entry) => + entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId + ) + // Why: drop the old ghost row that would violate (projectId, hostId) uniqueness with the re-added host's setup. + if (duplicate) { + setupsChanged = true + continue + } + setup.hostId = newHostId + setup.updatedAt = Date.now() + keptSetups.push(setup) + setupsChanged = true + } + if (setupsChanged) { + operations.state.projectHostSetups = keptSetups + } + // Why: repo-row and host-setup rewrites affect host-setup compatibility; meta-only rewrites don't, so gate the sync here. + if (repoIds.size > 0 || setupsChanged) { + operations.syncProjectHostSetupCompatibilityState() + } + if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) { + operations.scheduleSave() + } + return [...repoIds] +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts new file mode 100644 index 00000000000..7e1c07c1842 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -0,0 +1,172 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' +import { + MAX_CLAUDE_LIVE_PTY_SESSION_IDS, + MAX_REMOVED_SSH_TARGET_TOMBSTONES +} from '../restoring-sessions/pane-alias-normalization' +import { normalizeSshTarget } from './ssh-normalization' + +export type SshTargetStateOperations = { + state: StoreOwnedPersistedState + protectedSecrets: Pick + scheduleSave: () => void + flush: () => void +} + +export function getSshTargets(state: PersistedState): SshTarget[] { + return (state.sshTargets ?? []).map(normalizeSshTarget) +} + +export function getSshTarget(state: PersistedState, id: string): SshTarget | undefined { + const target = state.sshTargets?.find((entry) => entry.id === id) + return target ? normalizeSshTarget(target) : undefined +} + +export function addSshTarget(operations: SshTargetStateOperations, target: SshTarget): void { + operations.state.sshTargets ??= [] + operations.state.sshTargets.push(normalizeSshTarget(target)) + operations.scheduleSave() +} + +export function updateSshTarget( + operations: SshTargetStateOperations, + id: string, + updates: Partial> +): SshTarget | null { + const target = operations.state.sshTargets?.find((entry) => entry.id === id) + if (!target) { + return null + } + const normalized = normalizeSshTarget({ ...target, ...updates }) + // Why: Object.assign only adds keys, so anything normalization stripped (retired sync fields, implicit defaults) must be deleted off the live target. + const mutableTarget = target as Record + for (const key of Object.keys(mutableTarget)) { + if (!Object.hasOwn(normalized, key)) { + delete mutableTarget[key] + } + } + Object.assign(target, normalized) + operations.scheduleSave() + return { ...target } +} + +export function removeSshTarget(operations: SshTargetStateOperations, id: string): void { + const targets = operations.state.sshTargets ?? [] + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + const nextTargets = targets.filter((target) => target.id !== id) + const nextRecoveries = recoveries.filter((record) => record.targetId !== id) + if (nextTargets.length === targets.length && nextRecoveries.length === recoveries.length) { + return + } + operations.state.sshTargets = nextTargets + operations.state.sshPtyConsumerRecoveries = nextRecoveries + operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id)) + operations.scheduleSave() +} + +export function getClaudeLivePtySessionIds(state: PersistedState): string[] { + return [...(state.claudeLivePtySessionIds ?? [])] +} + +export function addClaudeLivePtySessionId( + operations: SshTargetStateOperations, + sessionId: string +): void { + if (sessionId.length === 0 || sessionId.length > 512) { + return + } + const ids = operations.state.claudeLivePtySessionIds ?? [] + if (ids.includes(sessionId)) { + return + } + // Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters. + operations.state.claudeLivePtySessionIds = [...ids, sessionId].slice( + -MAX_CLAUDE_LIVE_PTY_SESSION_IDS + ) + // Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch. + operations.flush() +} + +export function removeClaudeLivePtySessionId( + operations: SshTargetStateOperations, + sessionId: string +): void { + const ids = operations.state.claudeLivePtySessionIds ?? [] + if (!ids.includes(sessionId)) { + return + } + operations.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId) + operations.scheduleSave() +} + +export function getDeletedSshConfigAliases(state: PersistedState): string[] { + return [...(state.deletedSshConfigAliases ?? [])] +} + +export function addDeletedSshConfigAlias( + operations: SshTargetStateOperations, + alias: string +): void { + operations.state.deletedSshConfigAliases ??= [] + if (!operations.state.deletedSshConfigAliases.includes(alias)) { + operations.state.deletedSshConfigAliases.push(alias) + operations.scheduleSave() + } +} + +export function removeDeletedSshConfigAlias( + operations: SshTargetStateOperations, + alias: string +): void { + const current = operations.state.deletedSshConfigAliases + if (!current || !current.includes(alias)) { + return + } + operations.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias) + operations.scheduleSave() +} + +export function clearDeletedSshConfigAliases(operations: SshTargetStateOperations): void { + if ( + operations.state.deletedSshConfigAliases && + operations.state.deletedSshConfigAliases.length > 0 + ) { + operations.state.deletedSshConfigAliases = [] + operations.scheduleSave() + } +} + +export function getRemovedSshTargetTombstones(state: PersistedState): RemovedSshTargetTombstone[] { + return [...(state.removedSshTargetTombstones ?? [])] +} + +export function addRemovedSshTargetTombstone( + operations: SshTargetStateOperations, + tombstone: RemovedSshTargetTombstone +): void { + const existing = operations.state.removedSshTargetTombstones ?? [] + // Why: dedupe by oldTargetId so re-removing the same id can't stack duplicate tombstones; newest wins. + const filtered = existing.filter((entry) => entry.oldTargetId !== tombstone.oldTargetId) + // Cap the history so pathological churn can't grow the state file unbounded. + operations.state.removedSshTargetTombstones = [...filtered, tombstone].slice( + -MAX_REMOVED_SSH_TARGET_TOMBSTONES + ) + operations.scheduleSave() +} + +export function removeRemovedSshTargetTombstone( + operations: SshTargetStateOperations, + oldTargetId: string +): void { + const existing = operations.state.removedSshTargetTombstones + if (!existing?.some((entry) => entry.oldTargetId === oldTargetId)) { + return + } + operations.state.removedSshTargetTombstones = existing.filter( + (entry) => entry.oldTargetId !== oldTargetId + ) + operations.scheduleSave() +} diff --git a/src/main/persistence/loading-store/store-owned-state.ts b/src/main/persistence/loading-store/store-owned-state.ts new file mode 100644 index 00000000000..9c752b06f92 --- /dev/null +++ b/src/main/persistence/loading-store/store-owned-state.ts @@ -0,0 +1,8 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' + +/** + * The Store's live state object, never a copy. Extracted operations read and mutate it in place, so a caller that + * passes a clone or a spread silently loses every write. The Store stays its sole owner and is the only thing that + * may hand this reference out. + */ +export type StoreOwnedPersistedState = PersistedState diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts new file mode 100644 index 00000000000..d602be2b7aa --- /dev/null +++ b/src/main/persistence/loading-store/store.ts @@ -0,0 +1,3847 @@ +/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ +import { + readFileSync, + writeFileSync, + mkdirSync, + existsSync, + renameSync, + unlinkSync, + copyFileSync, + statSync +} from 'node:fs' +import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' +import { + durableWriteTempPath, + removeStaleDurableWriteTempFiles, + renameDurable, + writeFileDurableSync +} from '../../durable-file-write' +import { dirname } from 'node:path' +import { homedir } from 'node:os' +import { createHash, randomUUID } from 'node:crypto' +import type { + Automation, + AutomationCreateInput, + AutomationDispatchResult, + AutomationRun, + AutomationRunTrigger, + AutomationUpdateInput +} from '../../../shared/automations-types' +import { normalizeProxyUrl } from '../../../shared/network-proxy' +import { normalizeKagiSessionLink } from '../../../shared/browser-url' +import type { FolderWorkspace, WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' +import type { + PersistedMobileClientTabSelections, + PersistedState +} from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs +} from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import type { SparsePreset } from '../../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' +import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' +import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from '../../orca-profiles/profile-project-session-state' +import type { + RemovedSshTargetTombstone, + SshPtyConsumerRecovery, + SshRemotePtyLease, + SshTarget +} from '../../../shared/ssh-types' +import { isFolderRepo } from '../../../shared/repo-kind' +import { + getRepoExecutionHostId, + parseExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { + getDefaultPersistedState, + getDefaultOnboardingState, + getDefaultVoiceSettings, + getDefaultWorkspaceSession, + getWorktreeCardModeProperties, + isDefaultedCompactWorktreeCardProperties, + normalizeWorktreeCardProperties +} from '../../../shared/constants' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' +import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' +import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' +import { toRelaySshPtyId } from '../../providers/ssh-pty-id' +import { isTerminalLeafId } 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 { + backfillAutomationRunNumbers, + pruneAutomationRuns +} from '../../../shared/automation-run-retention' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { + addRetiredNames, + clampExhaustedTiers, + compactRetiredNames, + EMPTY_RETIRED_NAME_REGISTRY, + isEmptyRetiredNameRegistry, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' +import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' +import { normalizeTaskProviderSettings } from '../../../shared/task-providers' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../shared/mobile-pairing-custom-address' +import { normalizeOpenInApplications } from '../../../shared/open-in-applications' +import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' +import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' +import { normalizeAppIconId } from '../../../shared/app-icon' +import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { + normalizeFeatureInteractionTelemetryBuckets, + type FeatureInteractionId +} from '../../../shared/feature-interactions' +import { + parseCodexResetCreditAttemptLedger, + type CodexResetCreditAttemptLedger +} from '../../../shared/codex-reset-credit-attempt-ledger' +import { + DEFAULT_WORKSPACE_STATUS_ID, + normalizePersistedWorkspaceStatuses +} from '../../../shared/workspace-statuses' +import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' +import { + clearMissingProjectGroupMemberships, + createProjectGroup, + normalizeProjectGroups +} from '../../../shared/project-groups' +import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' +import { + mergeLegacyCommitMessageAiIntoSourceControlAi, + projectSourceControlAiToLegacyCommitMessageAi, + sourceControlAiSettingsFromLegacy +} from '../../../shared/source-control-ai' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' +import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../shared/osc52-clipboard-settings' +import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' +import { normalizeUiLanguage } from '../../../shared/ui-language' +import { ActiveViewPreference } from '../../active-view-preference' +import { + collectFolderWorkspaceDiffComments, + normalizeFolderWorkspaceDiffComments +} from '../../folder-workspace-diff-comments' +import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + getProfileTerminalScrollbackSnapshotRoot, + migrateWorkspaceSessionTerminalScrollbackSnapshots, + readTerminalScrollbackSnapshotSync, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' +import { + deleteRemovedTerminalScrollbackSnapshotsAsync, + migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync +} from '../../terminal-scrollback-snapshot-async-migration' +import { + isStartupDiagnosticsEnabled, + logStartupDiagnostic +} from '../../startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + ProtectedSecretPersistence, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from '../../protected-secret-persistence' +import { + isLegacyOpenCodeSessionCookie, + isLegacySshPtyOwnerLease +} from '../leasing-ssh-ptys/secret-validation' +import { getDataFile, getGithubCacheFile, readGithubCacheSnapshot } from './user-data-path' +import { + STALE_DURABLE_WRITE_TEMP_AGE_MS, + gcStaleWorktreeMeta, + normalizeWorktreeLinkedItemMetadata +} from '../tracking-repos/worktree-metadata-normalization' +import { + migrateAgentYoloDefaults, + migrateTerminalScrollbackRows, + migrateTerminalTuiScrollSensitivityDefault, + stripRetiredGlobalSettings +} from '../applying-settings/terminal-settings-migrations' +import { + normalizeRightSidebarTab, + normalizeShowDotfilesByWorktree, + normalizeSortBy +} from '../applying-settings/ui-selection-normalization' +import { + normalizeWorkspaceLineageByChildKey, + stripMainOwnedTelemetryMarkerFromUI +} from '../applying-settings/ui-interaction-merge' +import { + backfillLegacyAutomationContexts, + normalizeAutomationRunWorkspaceDisplayName +} from '../scheduling-automations/automation-context-migration' +import { + normalizeLoadedOnboardingState, + normalizeNotificationSettings, + readDeprecatedExperimentFlag, + readLegacySidekickFlag, + resolveSetupGuideSidebarDismissedOnLoad +} from '../applying-settings/onboarding-normalization' +import { + canonicalizePersistedFloatingWorkspaceDirectory, + normalizeFloatingWorkspaceTrustedCwds +} from '../restoring-sessions/floating-workspace-normalization' +import { + ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, + normalizeSshPtyConsumerRecovery, + normalizeSshRemotePtyLease, + normalizeSshTarget +} from '../leasing-ssh-ptys/ssh-normalization' +import { + type SshTargetStateOperations, + addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, + addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, + addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, + addSshTarget as addSshTargetOperation, + clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, + getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, + getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, + getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, + getSshTarget as getSshTargetOperation, + getSshTargets as getSshTargetsOperation, + removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, + removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, + removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, + removeSshTarget as removeSshTargetOperation, + updateSshTarget as updateSshTargetOperation +} from '../leasing-ssh-ptys/ssh-target-state' +import { + reassignSshTargetId as reassignSshTargetIdOperation, + type SshTargetReassignmentOperations +} from '../leasing-ssh-ptys/ssh-target-reassignment' +import { + getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, + markSshRemotePtyLease as markSshRemotePtyLeaseOperation, + markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, + markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, + markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, + markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, + removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, + removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, + type SshPtyLeaseOperations, + upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation +} from '../leasing-ssh-ptys/ssh-pty-lease-operations' +import { + getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, + removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, + type SshPtyConsumerRecoveryOperations, + upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation +} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' +import { + clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, + clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, + type SshPtyBindingCleanupOperations +} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' +import { + cloneLayoutNode, + layoutContainsLeafId, + preserveMissingLeafRecordEntries +} from '../restoring-sessions/terminal-layout-normalization' +import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { + normalizeClaudeLivePtySessionIds, + normalizeLegacyPaneKeyAliasEntries, + normalizeMigrationUnsupportedPtyEntries, + registerPersistedPaneKeyAlias +} from '../restoring-sessions/pane-alias-normalization' +import { + normalizePersistedPaneIdentityState, + normalizeWorkspaceSessionPaneIdentities, + remapAcknowledgedAgentPaneKeys, + remapSshRemotePtyLeaseLeafIds +} from '../restoring-sessions/workspace-pane-normalization' +import { + mergeProjectHostSetupCompatibilityState, + projectHostSetupCompatibilityStateEqual +} from '../tracking-repos/project-host-compatibility' +import { + cloneWorkspaceSessionState, + createMinimalPersistedTerminalTab +} from '../restoring-sessions/session-owner-fields' +import { + removeWorkspaceSessionOwner, + workspaceSessionOwnerPartitionForHost, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' +import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' +import { + createAutomation as createAutomationOperation, + deleteAutomation as deleteAutomationOperation, + listAutomations as listAutomationsOperation, + updateAutomation as updateAutomationOperation, + type AutomationDefinitionOperations +} from '../scheduling-automations/automation-definition-operations' +import { + createAutomationRun as createAutomationRunOperation, + listAutomationRuns as listAutomationRunsOperation, + snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, + updateAutomationRun as updateAutomationRunOperation, + type AutomationRunOperations +} from '../scheduling-automations/automation-run-operations' +import { + advanceAutomationNextRun as advanceAutomationNextRunOperation, + getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation +} from '../scheduling-automations/automation-schedule-operations' +import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' +import { + updateSettings as updateSettingsOperation, + type SettingsMutationOperations +} from '../applying-settings/settings-update' +import { getPersistedUI } from '../applying-settings/ui-state-read' +import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' +import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' +import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' +import { + recordFeatureInteraction as recordFeatureInteractionOperation, + type FeatureInteractionOperations +} from '../applying-settings/feature-interaction-recording' +import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' +import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. +const BACKUP_COUNT = 5 +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 +const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId' +]) + +function logPersistenceStartupMilestone( + event: string, + details: Record = {} +): void { + if (isStartupDiagnosticsEnabled()) { + logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + } +} + +function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { + return Object.keys(patch).some((key) => + WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) + ) +} + +function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + +/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. + * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ +function parseWorkspaceSessionsByHostId( + raw: unknown, + defaults: WorkspaceSessionState +): { partitions: Partial>; repaired: boolean } { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { partitions: {}, repaired: raw !== undefined } + } + let repaired = false + const partitions: Partial> = {} + for (const [key, value] of Object.entries(raw as Record)) { + const hostId = normalizeExecutionHostId(key) + // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. + if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { + continue + } + const result = parseWorkspaceSessionSalvaging(value) + if (!result.ok) { + repaired = true + console.error( + `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, + result.error + ) + continue + } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } + partitions[hostId] = { ...defaults, ...result.value } + } + return { partitions, repaired } +} + +function backupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false + ) +} + +function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { + const isPlainArray = Array.isArray(row) + const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined + const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] + const names = new Set() + for (const entry of rawNames) { + if (typeof entry !== 'string') { + continue + } + const normalized = normalizeRetirableGeneratedName(entry) + if (normalized) { + names.add(normalized) + } + } + return compactRetiredNames({ + exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), + names: [...names] + }) +} + +function normalizeRetiredNameRegistryMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const byRepo: Record = {} + for (const [repoId, row] of Object.entries(value as Record)) { + if (!repoId) { + continue + } + const registry = normalizeRetiredNameRegistry(row) + if (!isEmptyRetiredNameRegistry(registry)) { + byRepo[repoId] = registry + } + } + return byRepo +} + +function deleteRemovedTerminalScrollbackSnapshots( + prior: WorkspaceSessionState | undefined, + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage +): void { + if (!prior) { + return + } + const nextRefs = collectTerminalScrollbackSnapshotRefs(next) + for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { + if (!nextRefs.has(ref)) { + deleteTerminalScrollbackSnapshotSync(ref, storage) + } + } +} + +export type StoreOptions = { + dataFile?: string +} + +export type PtyBindingSourceExpectation = { + worktreeId?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string +} + +export class Store { + // Why readonly: the operations wrappers below capture this reference once and are memoized. + private readonly state: PersistedState + private readonly dataFile: string + private readonly activeViewPreference: ActiveViewPreference + private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage + private writeTimer: ReturnType | null = null + private pendingWrite: Promise | null = null + private pendingSnapshotFileWork: Promise | null = null + private readonly staleTempCleanup: Promise + private writeGeneration = 0 + private inFlightAsyncTmpFile: string | null = null + // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. + private backupRotationInFlight = false + // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. + private writesFrozen = false + /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ + private quitFlushStarted = false + private quitFlushPromise: Promise | null = null + // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. + private lastWrittenStateHash: string | null = null + private lastDurableWriteGeneration = -1 + private firstPendingSaveAt: number | null = null + private githubCacheDirty = false + private githubCacheGeneration = 0 + private pendingGithubCacheWrite: Promise | null = null + private readonly staleGithubCacheTempCleanup: Promise + private readonly gitUsernameCache = new Map() + private readonly protectedSecrets = new ProtectedSecretPersistence() + private loadNeedsSave = false + private settingsChangeListeners = new Set< + ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + >() + private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() + + constructor(options: StoreOptions = {}) { + // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. + this.dataFile = options.dataFile ?? getDataFile() + this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { + minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS + }) + this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( + getGithubCacheFile(this.dataFile), + { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } + ) + const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) + const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) + this.terminalScrollbackSnapshotStorage = { + snapshotRoot: profileSnapshotRoot, + fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot + } + const loaded = this.load() + const normalized = normalizePersistedPaneIdentityState(loaded) + this.state = normalized.state + // Why: activeView is a frequent, tiny preference; keeping it beside the + // profile avoids serializing the multi-MB recovery store on navigation. + this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) + const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() + this.hydrateFolderWorkspaceDiffComments() + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + setMigrationUnsupportedPtyPersistenceListener((entries) => { + this.state.migrationUnsupportedPtyEntries = entries + this.scheduleSave() + }) + agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { + this.state.legacyPaneKeyAliasEntries = entries + this.scheduleSave() + }) + if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { + // Why: rewrite legacy pane:1 leaves so older renderer writes can't revive them; other migrations also set loadNeedsSave. + this.scheduleSave() + } + } + + // Why: notes live top-level on disk so an older build's field-by-field + // normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here. + private hydrateFolderWorkspaceDiffComments(): void { + const stored = this.state.folderWorkspaceDiffComments + let relocatedInline = false + for (const workspace of this.state.folderWorkspaces ?? []) { + if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { + // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the + // older map untouched, so inline is the last notes-aware write. Also makes the relocation + // durable even if the user never edits anything this session. + relocatedInline = true + continue + } + const comments = stored?.[workspace.id] + // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. + if (Array.isArray(comments) && comments.length > 0) { + workspace.diffComments = comments + } + } + if (relocatedInline) { + this.loadNeedsSave = true + } + // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in + // state would make it a stale second source of truth that getDurableState() spreads back out. + delete this.state.folderWorkspaceDiffComments + } + + private adaptFlatFolderScanProjectGroups(): boolean { + // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. + const groups = this.state.projectGroups ?? [] + const repos = this.state.repos + if (groups.length === 0 || repos.length === 0) { + return false + } + + let changed = false + let maxOrder = -1 + for (const group of groups) { + maxOrder = Math.max(maxOrder, group.tabOrder) + } + + const childGroupIds = new Set( + groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) + ) + const initialGroupCount = groups.length + for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { + const rootGroup = groups[groupIndex] + if (!rootGroup) { + continue + } + if ( + rootGroup.createdFrom !== 'folder-scan' || + !rootGroup.parentPath || + rootGroup.parentGroupId || + childGroupIds.has(rootGroup.id) + ) { + continue + } + const rootPath = rootGroup.parentPath + const repoCandidates = repos.filter( + (repo) => + !isFolderRepo(repo) && + repo.projectGroupId === rootGroup.id && + isPathInsideOrEqual(rootPath, repo.path) + ) + if (repoCandidates.length < 2) { + continue + } + + const resolver = createNestedProjectGroupResolver({ + parentPath: rootPath, + groupName: rootGroup.name, + mode: 'group', + repoPaths: repoCandidates.map((repo) => repo.path), + createGroup: (input) => { + if (!input.parentGroupId) { + return rootGroup + } + maxOrder += 1 + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + }) + groups.push(group) + changed = true + return group + } + }) + const nextOrderByGroupId = new Map() + for (const repo of repoCandidates) { + const group = resolver.getGroupForRepo(repo.path) + if (!group) { + continue + } + const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 + nextOrderByGroupId.set(group.id, nextOrder + 1) + if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { + repo.projectGroupId = group.id + repo.projectGroupOrder = nextOrder + changed = true + } + } + } + return changed + } + + // Why (#1158): debounced writes fire ~every 300ms; throttle backups to distinct moments, not near-identical snapshots. + 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 separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible + // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. + private async shouldRotateBackupsAsync(dataFile: string): Promise { + try { + const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs + return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. + private async rotateBackupsAsync(dataFile: string): Promise { + if (this.backupRotationInFlight) { + return + } + this.backupRotationInFlight = true + try { + if (!(await this.shouldRotateBackupsAsync(dataFile))) { + return + } + if (!(await exists(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) + // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an + // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. + if (await exists(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) + }) + } finally { + this.backupRotationInFlight = false + } + } + + 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 "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. + const dataFile = this.dataFile + const fileExistedOnLoad = existsSync(dataFile) + logPersistenceStartupMilestone('persistence-load-start', { + fileExists: fileExistedOnLoad + }) + + let result: PersistedState | null = null + try { + if (fileExistedOnLoad) { + const readStartedAt = performance.now() + const raw = readFileSync(dataFile, 'utf-8') + logPersistenceStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logPersistenceStartupMilestone('persistence-json-parse-start') + const parsed = JSON.parse(raw) as PersistedState + logPersistenceStartupMilestone('persistence-json-parse-done') + + // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. + if (parsed.settings?.opencodeSessionCookie) { + parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) + } + if (parsed.settings?.httpProxyUrl) { + const decryptedProxy = this.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + this.loadNeedsSave = true + } + } + if (parsed.ui?.browserKagiSessionLink) { + parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) + } + parsed.sshPtyConsumerRecoveries = ( + Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] + ) + .map((record) => + normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) + ) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + + // Merge with defaults in case new fields were added + const homeDir = homedir() + const defaults = getDefaultPersistedState(homeDir) + const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( + Array.isArray(parsed.repos) ? parsed.repos : [], + parsed.settings?.worktreeVisibilityDefaults + ) + if (migratedExternalVisibility.changed) { + this.loadNeedsSave = true + } + const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) + if (migratedTerminalScrollback.needsSave) { + this.loadNeedsSave = true + } + if ( + parsed.settings && + typeof parsed.settings === 'object' && + Object.hasOwn(parsed.settings, 'enableGitHubAttribution') + ) { + this.loadNeedsSave = true + } + const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( + parsed.settings + ) + if (migratedTerminalTuiScrollSensitivity.needsSave) { + this.loadNeedsSave = true + } + const rawSourceControlAi = parsed.settings?.sourceControlAi + const rawSourceControlAiMissing = rawSourceControlAi === undefined + const rawSourceControlAiActionsMissing = + rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined + if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { + this.loadNeedsSave = true + } + const legacyCommitMessageAi = parsed.settings?.commitMessageAi + const migratedSourceControlAi = rawSourceControlAiMissing + ? sourceControlAiSettingsFromLegacy( + legacyCommitMessageAi ?? defaults.settings.commitMessageAi + ) + : mergeLegacyCommitMessageAiIntoSourceControlAi( + parsed.settings?.sourceControlAi, + legacyCommitMessageAi + ) + // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. + 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 builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. + const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers + ? (parsed.settings?.floatingTerminalEnabled ?? true) + : true + // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). + const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) + const osc52ClipboardNoticePending = + osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || + parsed.ui?.osc52ClipboardDefaultOnNoticePending === true + if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { + this.loadNeedsSave = true + } + const floatingTerminalCwdMigrated = + parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true + // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. + const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated + ? !parsed.settings?.floatingTerminalCwd + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + : parsed.settings?.floatingTerminalCwd === undefined + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( + parsed.settings?.floatingTerminalTrustedCwds, + homeDir + ) + const migratedFloatingTerminalTrustedCwds = [ + ...normalizedFloatingTerminalTrustedCwds.trustedCwds + ] + const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd + const shouldTrustLegacyFloatingTerminalCwd = + !floatingTerminalCwdMigrated && + typeof rawLegacyFloatingTerminalCwd === 'string' && + rawLegacyFloatingTerminalCwd.trim().length > 0 && + rawLegacyFloatingTerminalCwd.trim() !== '~' + if (!floatingTerminalCwdMigrated) { + this.loadNeedsSave = true + } + if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { + const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( + rawLegacyFloatingTerminalCwd, + homeDir + ) + if ( + canonicalLegacyCwd && + !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd) + ) { + // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. + migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) + normalizedFloatingTerminalTrustedCwds.changed = true + } + } + if (normalizedFloatingTerminalTrustedCwds.changed) { + this.loadNeedsSave = true + } + const experimentalActivityDefaultedOffForAllUsers = + parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true + // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. + const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers + ? (parsed.settings?.experimentalActivity ?? false) + : false + const autoRenameBranchFromWorkDefaultedOn = + parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true + // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. + const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( + parsed.settings + ) + const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== + migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + this.loadNeedsSave = true + } + const migratedTerminalLineHeight = normalizeTerminalLineHeight( + parsed.settings?.terminalLineHeight + ) + const terminalRightClickToPasteDefaultedForPlatform = + parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true + if (!terminalRightClickToPasteDefaultedForPlatform) { + this.loadNeedsSave = true + } + if ( + parsed.settings?.terminalLineHeight !== undefined && + parsed.settings.terminalLineHeight !== migratedTerminalLineHeight + ) { + this.loadNeedsSave = true + } + const rawTaskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: parsed.settings?.visibleTaskProviders, + defaultTaskSource: parsed.settings?.defaultTaskSource + }) + const visibleTaskProvidersDefaultedForJira = + parsed.settings?.visibleTaskProvidersDefaultedForJira === true + const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira + ? rawTaskProviderSettings.visibleTaskProviders + : rawTaskProviderSettings.visibleTaskProviders.includes('jira') + ? rawTaskProviderSettings.visibleTaskProviders + : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: migratedVisibleTaskProviders, + defaultTaskSource: rawTaskProviderSettings.defaultTaskSource + }) + const primarySelectionDefaultedForLinux = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true + const primarySelectionDefaultedForTerminalDefaults = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true + const primarySelectionPlatformDefaultEnabled = + defaults.settings.primarySelectionMiddleClickPaste === true + const primarySelectionAlreadyDefaultedForPlatform = + primarySelectionDefaultedForTerminalDefaults || + (process.platform === 'linux' && primarySelectionDefaultedForLinux) + const migratePrimarySelectionPlatformDefault = + primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform + const stampPrimarySelectionTerminalDefaults = + primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults + if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { + this.loadNeedsSave = true + } + if (!visibleTaskProvidersDefaultedForJira) { + this.loadNeedsSave = true + } + const claudeAgentTeamsDefaultDisabledMigrated = + parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true + if (!claudeAgentTeamsDefaultDisabledMigrated) { + this.loadNeedsSave = true + } + const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( + parsed.settings?.disabledTuiAgents + ) + const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) + if ( + parsed.settings?.agentYoloDefaultsMigrated !== true || + hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || + hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) + ) { + this.loadNeedsSave = true + } + if ( + !claudeAgentTeamsDefaultDisabledMigrated && + !migratedDisabledTuiAgents.includes('claude-agent-teams') + ) { + migratedDisabledTuiAgents.push('claude-agent-teams') + } + const migratedWindowsRuntimeDefault = + parsed.settings?.localWindowsRuntimeDefault === undefined + ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime + : parsed.settings.localWindowsRuntimeDefault + if ( + parsed.settings?.localWindowsRuntimeDefault === undefined && + migratedWindowsRuntimeDefault.kind === 'wsl' + ) { + this.loadNeedsSave = true + } + // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. + const localAccountRuntimeAlreadyMigrated = + parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true + const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = + localAccountRuntimeAlreadyMigrated + ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) + : parsed.settings?.localAccountRuntime === 'wsl' + ? 'wsl' + : 'auto' + if (!localAccountRuntimeAlreadyMigrated) { + this.loadNeedsSave = true + } + if (!autoRenameBranchFromWorkDefaultedOn) { + this.loadNeedsSave = true + } + const normalizedOnboarding = normalizeLoadedOnboardingState( + parsed.onboarding, + defaults.onboarding + ) + if (!parsed.onboarding) { + this.loadNeedsSave = true + } + const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) + const loadedCompactWorktreeCards = + parsed.settings?.compactWorktreeCards ?? + parsed.settings?.experimentalCompactWorktreeCards ?? + defaults.settings.compactWorktreeCards + const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + parsed.settings?.mobilePairingCustomAddress + ) + const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses + const mobilePairingCustomAddresses = mobilePairingCustomAddress + ? addMobilePairingCustomAddress( + normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), + mobilePairingCustomAddress + ) + : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) + if ( + parsed.settings?.mobilePairingCustomAddress !== undefined && + parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress + ) { + this.loadNeedsSave = true + } + const customAddressesMatch = + Array.isArray(rawMobilePairingCustomAddresses) && + rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && + rawMobilePairingCustomAddresses.every( + (address, index) => address === mobilePairingCustomAddresses[index] + ) + if ( + (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && + !customAddressesMatch + ) { + this.loadNeedsSave = true + } + const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( + parsed.settings?.sourceControlGroupOrder + ) + if ( + parsed.settings?.sourceControlGroupOrder !== undefined && + parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder + ) { + this.loadNeedsSave = true + } + result = { + ...defaults, + ...parsed, + featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( + parsed.featureInteractionTelemetryBuckets + ), + projectGroups: normalizedProjectGroups, + repos: migratedExternalVisibility.repos, + folderWorkspaces: normalizeFolderWorkspaces( + parsed.folderWorkspaces, + normalizedProjectGroups + ), + folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( + parsed.folderWorkspaceDiffComments + ), + worktreeLineageById: parsed.worktreeLineageById ?? {}, + mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( + parsed.mobileClientTabSelectionsByDeviceId + ), + workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( + parsed.workspaceLineageByChildKey + ), + settings: { + ...defaults.settings, + // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. + ...stripRetiredGlobalSettings(parsed.settings), + worktreeVisibilityDefaults: migratedExternalVisibility.defaults, + prBotAuthorOverrides: normalizePRBotAuthorOverrides( + parsed.settings?.prBotAuthorOverrides + ), + // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. + experimentalPet: + parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, + // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. + primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault + ? true + : (parsed.settings?.primarySelectionMiddleClickPaste ?? + defaults.settings.primarySelectionMiddleClickPaste), + primarySelectionMiddleClickPasteDefaultedForLinux: + primarySelectionDefaultedForLinux || + (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: + primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, + ...migratedAutoRenameBranchFromWork, + ...migratedTerminalCursorStyle, + terminalLineHeight: migratedTerminalLineHeight, + // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. + terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform + ? (parsed.settings?.terminalRightClickToPaste ?? + defaults.settings.terminalRightClickToPaste) + : parsed.settings?.terminalRightClickToPaste === false + ? false + : defaults.settings.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, + ...migratedTerminalTuiScrollSensitivity.settings, + experimentalActivity: migratedExperimentalActivity, + experimentalActivityDefaultedOffForAllUsers: true, + // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. + compactWorktreeCards: loadedCompactWorktreeCards, + experimentalCompactWorktreeCards: undefined, + terminalMacOptionAsAlt: migratedOptionAsAlt, + terminalMacOptionAsAltMigrated: true, + localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, + localAccountRuntime: migratedLocalAccountRuntime, + localAccountRuntimeDefaultedToAutoForAllUsers: true, + ...migratedOsc52Clipboard, + floatingTerminalEnabled: migratedFloatingTerminalEnabled, + floatingTerminalDefaultedForAllUsers: true, + floatingTerminalCwd: migratedFloatingTerminalCwd, + floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, + floatingTerminalCwdMigratedToAppWorkspace: true, + terminalScrollbackRows: migratedTerminalScrollback.rows, + terminalQuickCommands: normalizeTerminalQuickCommands( + parsed.settings?.terminalQuickCommands + ), + terminalCustomThemes: normalizeTerminalCustomThemes( + parsed.settings?.terminalCustomThemes + ), + appIcon: normalizeAppIconId(parsed.settings?.appIcon), + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. + minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, + uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), + defaultTaskSource: taskProviderSettings.defaultTaskSource, + visibleTaskProviders: taskProviderSettings.visibleTaskProviders, + visibleTaskProvidersDefaultedForJira: true, + terminalShortcutPolicy: normalizeTerminalShortcutPolicy( + parsed.settings?.terminalShortcutPolicy + ), + disabledTuiAgents: migratedDisabledTuiAgents, + ...migratedAgentYoloDefaults, + claudeAgentTeamsDefaultDisabledMigrated: true, + openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { + seedDefaults: true + }), + notifications: normalizeNotificationSettings(parsed.settings?.notifications), + sourceControlAi: migratedSourceControlAi, + sourceControlGroupOrder: normalizedSourceControlGroupOrder, + // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. + commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( + migratedSourceControlAi, + parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi + ), + voice: { + ...getDefaultVoiceSettings(), + ...parsed.settings?.voice + } + }, + // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. + ui: (() => { + const rawSort = parsed.ui?.sortBy + const sort = normalizeSortBy(rawSort) + const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' + const rightSidebarOpen = + typeof parsed.ui?.rightSidebarOpen === 'boolean' + ? parsed.ui.rightSidebarOpen + : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' + ? parsed.settings.rightSidebarOpenByDefault + : defaults.ui.rightSidebarOpen + if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { + this.loadNeedsSave = true + } + const workspaceStatusesDefaultOrderMigrated = + parsed.ui?._workspaceStatusesDefaultOrderMigrated === true + // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. + const workspaceStatusesReorderedDefaultRepaired = + parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true + // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. + 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. + const workspaceStatusesDefaultVisualsMigrated = + parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true + const workspaceStatuses = normalizePersistedWorkspaceStatuses( + parsed.ui?.workspaceStatuses, + { + migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, + repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, + migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated + } + ) + if ( + !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesReorderedDefaultRepaired || + !workspaceStatusesDefaultWorkflowMigrated || + !workspaceStatusesDefaultVisualsMigrated + ) { + this.loadNeedsSave = true + } + const rawCardProps = parsed.ui?.worktreeCardProperties + const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true + const expandedCardPropsMigrated = + parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = + parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === 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 needsLegacyDefaultedCompactMigration = + loadedCompactWorktreeCards && + parsed.ui?._worktreeCardModeDefaulted === true && + isDefaultedCompactWorktreeCardProperties(rawCardProps) + const migratedCardProps = (() => { + if (!Array.isArray(rawCardProps)) { + return undefined + } + if (needsLegacyDefaultedCompactMigration) { + return getWorktreeCardModeProperties('Compact') + } + const candidate = needsInlineAgentsMigration + ? [...rawCardProps, 'inline-agents' as const] + : rawCardProps + const expandedCandidate = (() => { + if (expandedCardPropsMigrated) { + return candidate + } + const next = [...candidate] + // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. + if (candidate.includes('issue') && !candidate.includes('linear-issue')) { + next.push('linear-issue' as const) + } + if (!candidate.includes('ports')) { + next.push('ports' as const) + } + return next + })() + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) + const changed = + normalized.length !== rawCardProps.length || + normalized.some((property, index) => property !== rawCardProps[index]) + return changed ? normalized : undefined + })() + if ( + migratedCardProps !== undefined || + !inlineAgentsMigrated || + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted + ) { + this.loadNeedsSave = true + } + const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( + parsed.ui?.setupGuideSidebarDismissed, + normalizedOnboarding + ) + if ( + parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && + (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) + ) { + this.loadNeedsSave = true + } + // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + this.loadNeedsSave = true + } + return { + ...defaults.ui, + // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. + worktreeCardProperties: getWorktreeCardModeProperties( + loadedCompactWorktreeCards ? 'Compact' : 'Default' + ), + ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), + // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. + rightSidebarOpen, + rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), + setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, + setupGuideBrowserMilestoneMigrated: + typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' + ? parsed.ui.setupGuideBrowserMilestoneMigrated + : false, + setupGuideBrowserMilestoneLegacyComplete: + parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, + // Why persist rather than notify inline: the flip lands during load, before any + // window exists, and it must survive a crash before the user ever sees the notice. + osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, + sortBy: migrate ? ('smart' as const) : sort, + showDotfilesByWorktree: normalizeShowDotfilesByWorktree( + parsed.ui?.showDotfilesByWorktree + ), + workspaceStatuses, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true, + _sortBySmartMigrated: true, + ...(migratedCardProps !== undefined + ? { worktreeCardProperties: migratedCardProps } + : {}), + // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. + _inlineAgentsDefaultedForExperiment: true, + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + } + })(), + // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. + workspaceSession: (() => { + if (parsed.workspaceSession === undefined) { + return defaults.workspaceSession + } + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) + if (!result.ok) { + console.error( + '[persistence] Corrupt workspace session, using defaults:', + result.error + ) + return defaults.workspaceSession + } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } + return { ...defaults.workspaceSession, ...result.value } + })(), + // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. + workspaceSessionsByHostId: (() => { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } + return partitions + })(), + sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter( + (alias): alias is string => typeof alias === 'string' + ) + : [], + retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap( + parsed.retiredWorktreeNamesByRepo + ), + retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( + parsed.retiredWorktreeNamesByNamespace + ), + sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) + .map(normalizeSshRemotePtyLease) + .filter((lease): lease is SshRemotePtyLease => lease !== null), + sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, + claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), + migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( + parsed.migrationUnsupportedPtyEntries + ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( + parsed.legacyPaneKeyAliasEntries + ), + automations: Array.isArray(parsed.automations) ? parsed.automations : [], + automationRuns: (() => { + if (!Array.isArray(parsed.automationRuns)) { + return [] + } + const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) + // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. + if (runs.length !== parsed.automationRuns.length) { + this.loadNeedsSave = true + } + return runs + })(), + onboarding: normalizedOnboarding + } + } + } catch (err) { + console.error('[persistence] Failed to load primary state, trying backups:', err) + } + + // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. + 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()) + } + + const workspaceSession = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) + ) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + workspaceSession, + this.terminalScrollbackSnapshotStorage + ) + if (migratedScrollback.changed) { + this.loadNeedsSave = true + } + + const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) + if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { + this.loadNeedsSave = true + } + + const automationContextMigration = backfillLegacyAutomationContexts({ + ...result, + repos, + ...projectHostSetupCompatibility + }) + if (automationContextMigration.changed) { + this.loadNeedsSave = true + } + result = { + ...result, + automations: automationContextMigration.state.automations, + automationRuns: automationContextMigration.state.automationRuns + } + + const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ + ...result, + repos, + ...projectHostSetupCompatibility, + workspaceSession: migratedScrollback.session + }) + if (folderScopeConnectionMigration.changed) { + this.loadNeedsSave = true + } + result = folderScopeConnectionMigration.state + + if (normalizeWorktreeLinkedItemMetadata(result)) { + this.loadNeedsSave = true + } + + if (gcStaleWorktreeMeta(result) > 0) { + this.loadNeedsSave = true + } + + const migrated = this.migrateTabSwitchKeybindings( + this.migrateTelemetry(result, fileExistedOnLoad), + fileExistedOnLoad + ) + + // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. + this.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache + } + + logPersistenceStartupMilestone('persistence-load-done', { + repos: migrated.repos.length, + workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) + }) + return migrated + } + + // One-shot telemetry cohort migration: seeds existedBeforeTelemetryRelease, optedIn, and installId (no-op once set). + // One-shot tab-switch cohort freeze: fileExistedOnLoad tells existing vs fresh only on the first launch, so persist now. + private migrateTabSwitchKeybindings( + state: PersistedState, + fileExistedOnLoad: boolean + ): PersistedState { + const existing = state.settings?.tabSwitchKeybindingSeed + if (existing === 'pending' || existing === 'done') { + return state + } + // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. + this.loadNeedsSave = true + return { + ...state, + settings: { + ...state.settings, + // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. + tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' + } + } + } + + private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.telemetry + // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. + 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: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. + const resolvedExistedBefore = + typeof existing?.existedBeforeTelemetryRelease === 'boolean' + ? existing.existedBeforeTelemetryRelease + : fileExistedOnLoad + return { + ...state, + settings: { + ...state.settings, + telemetry: { + ...existing, + existedBeforeTelemetryRelease: resolvedExistedBefore, + // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when 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() + } + } + } + } + + // Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s. + private static SAVE_DEBOUNCE_MS = 1_000 + private static SAVE_MAX_WAIT_MS = 5_000 + + private scheduleSave(): void { + // Why: once the quit flush has snapshotted, a newly debounced write would fire during + // teardown with nothing awaiting it, and the process can exit mid-rename. The quit + // flush is the last write by construction. + if (this.quitFlushStarted) { + return + } + this.writeGeneration += 1 + const now = Date.now() + this.firstPendingSaveAt ??= now + if (this.writeTimer) { + clearTimeout(this.writeTimer) + } + const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) + const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) + this.writeTimer = setTimeout(() => { + this.writeTimer = null + this.firstPendingSaveAt = null + void this.enqueueWrite() + }, delay) + } + + private enqueueWrite(): Promise { + const previousWrite = Promise.all([ + this.pendingWrite ?? this.staleTempCleanup, + this.pendingSnapshotFileWork ?? Promise.resolve() + ]).then(() => {}) + const write = previousWrite.then(() => this.writeToDiskAsync()) + const trackedWrite = write + .catch((err) => { + console.error('[persistence] Failed to write state:', err) + }) + .finally(() => { + if (this.pendingWrite === trackedWrite) { + this.pendingWrite = null + } + }) + this.pendingWrite = trackedWrite + return write + } + + /** Wait for any in-flight async disk write to complete. Used in tests. */ + async waitForPendingWrite(): Promise { + await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) + } + + // Why githubCache is omitted: memory-only this session (see getGithubCacheFile), so refreshes never touch the durable file. + private getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.state + return durable + } + + // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. + private buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { + // Why sentinels (not a blob/key string match): the substitution must be + // position-exact. A plain search for the ciphertext — or even for a + // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an + // agentDefaultEnv var named after a secret field, or a value equal to a + // ciphertext), which would substitute the wrong site and let two DISTINCT + // states normalize equal → a silently dropped write (data loss), reachable + // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot + // random UUID can't occur anywhere else in the serialized state (the user + // sets their data before it is minted), so it appears exactly once. + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { + return blob + } + const sentinel = `orca-secret-slot-${randomUUID()}` + secretSubs.push({ sentinel, blob, hashValue }) + return sentinel + } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } + // Why: clone before encrypting secrets so in-memory this.state stays plaintext. + const stateToSave = { + ...this.getDurableState(), + // Why both keys unconditionally: the explicit keys always win over the spread, and + // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. + // The strip builds a new array here only; this.state records keep their notes in memory. + folderWorkspaces: (this.state.folderWorkspaces ?? []).map( + ({ diffComments: _relocated, ...rest }) => rest + ), + folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces), + sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ + ...record, + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) + })), + settings: { + ...stripRetiredGlobalSettings(this.state.settings), + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.state.settings.httpProxyUrl ?? '' + ) + }, + ui: { + ...this.state.ui, + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.state.ui.browserKagiSessionLink + ) + } + } + // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. + // One full-state stringify; secret slots currently hold sentinels. + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } + } + + // Why: async writes avoid blocking the main Electron thread on every debounced save. + private async writeToDiskAsync(): Promise { + if (this.writesFrozen) { + return + } + const gen = this.writeGeneration + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() + // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. + if (stateHash === this.lastWrittenStateHash) { + this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) + return + } + const dataFile = this.dataFile + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = durableWriteTempPath(dataFile) + + // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. + let renamed = false + try { + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } + // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. + if (this.writeGeneration !== gen) { + return + } + this.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { + throw error + } + } finally { + if (this.inFlightAsyncTmpFile === tmpFile) { + this.inFlightAsyncTmpFile = null + } + } + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. + if (renamed && this.writeGeneration === gen) { + this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + } else if (renamed) { + this.lastWrittenStateHash = null + } + if (renamed) { + this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (!renamed) { + return + } + // Why (#1158): rotate only after the primary rename while this write still owns its generation. + if (this.writeGeneration !== gen) { + return + } + await this.rotateBackupsAsync(dataFile) + } + + // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. + private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { + if (this.writesFrozen) { + return + } + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() + // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. + if (!opts.force && stateHash === this.lastWrittenStateHash) { + return + } + const dataFile = this.dataFile + 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: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. + let renamed = false + try { + // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty + // content after power loss, losing projects/tabs back to the newest usable .bak slot. + writeFileDurableSync(tmpFile, dataFile, payload) + renamed = true + this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + this.lastDurableWriteGeneration = Math.max( + this.lastDurableWriteGeneration, + this.writeGeneration + ) + } finally { + if (!renamed) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup; the write already failed, swallow secondary error. + } + } + } + const now = Date.now() + if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { + this.rotateBackupsSync(dataFile) + } + } + + flushOrThrow(): void { + if (this.quitFlushStarted) { + throw new Error('Cannot synchronously flush after final persistence has started') + } + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + this.firstPendingSaveAt = null + const asyncWriteWasInFlight = this.pendingWrite !== null + // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. + this.writeGeneration++ + if (this.inFlightAsyncTmpFile) { + try { + unlinkSync(this.inFlightAsyncTmpFile) + this.inFlightAsyncTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void this.enqueueWrite().catch(() => {}) + throw error + } + } + } + // Why: later async flushes must remain serialized behind the invalidated writer. + this.writeToDiskSync({ + force: asyncWriteWasInFlight, + skipBackupRotation: this.backupRotationInFlight + }) + } + + flushActiveViewPreferenceOrThrow(): void { + this.activeViewPreference.flushOrThrow() + } + + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { + return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) + } + + replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { + if (this.writesFrozen) { + throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') + } + const next = parseCodexResetCreditAttemptLedger(ledger) + const previous = this.state.codexResetCreditAttemptLedger + ? structuredClone(this.state.codexResetCreditAttemptLedger) + : undefined + this.state.codexResetCreditAttemptLedger = next + try { + this.flushOrThrow() + } catch (error) { + // Why: callers use a successful return as the durability barrier before + // handing a scarce-credit mutation to the provider. + this.state.codexResetCreditAttemptLedger = previous + throw error + } + } + + // ── Repos ────────────────────────────────────────────────────────── + + getProfileStorageDirectory(): string { + return dirname(this.dataFile) + } + + private projectHostOperations: ProjectHostPersistenceOperations | null = null + + private getProjectHostOperations(): ProjectHostPersistenceOperations { + this.projectHostOperations ??= new ProjectHostPersistenceOperations({ + state: this.state, + gitUsernameCache: this.gitUsernameCache, + hydrateRepo: (repo) => this.hydrateRepo(repo), + updateRepoBackedProjectHostSetup: (setup, repo, updates) => + this.updateRepoBackedProjectHostSetup(setup, repo, updates), + updateIndependentProjectHostSetup: (setup, updates) => + this.updateIndependentProjectHostSetup(setup, updates), + removeProjectForHost: (id, hostId) => this.removeProjectForHost(id, hostId), + scheduleSave: () => this.scheduleSave() + }) + return this.projectHostOperations + } + + getRepos(): Repo[] { + return this.getProjectHostOperations().getRepos() + } + + getProjects(): Project[] { + return this.getProjectHostOperations().getProjects() + } + + updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { + return this.getProjectHostOperations().updateProject(id, updates) + } + + getProjectHostSetups(): ProjectHostSetup[] { + return this.getProjectHostOperations().getProjectHostSetups() + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + return this.getProjectHostOperations().createProjectHostSetup(args) + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + return this.getProjectHostOperations().updateProjectHostSetup(args) + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + return this.getProjectHostOperations().deleteProjectHostSetup(args) + } + + getRepoCount(): number { + return this.getProjectHostOperations().getRepoCount() + } + + getRepo(id: string): Repo | undefined { + return this.getProjectHostOperations().getRepo(id) + } + + setResolvedRepoGitUsername(id: string, username: string): boolean { + return this.getProjectHostOperations().setResolvedRepoGitUsername(id, username) + } + + private projectGroupOperations: ProjectGroupPersistenceOperations | null = null + + private getProjectGroupOperations(): ProjectGroupPersistenceOperations { + this.projectGroupOperations ??= new ProjectGroupPersistenceOperations({ + state: this.state, + scheduleSave: () => this.scheduleSave(), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), + pruneMobileClientTabSelections: (matchesWorktreeId) => + this.pruneMobileClientTabSelections(matchesWorktreeId) + }) + return this.projectGroupOperations + } + + getProjectGroups(): ProjectGroup[] { + return this.getProjectGroupOperations().getProjectGroups() + } + + createProjectGroup( + input: Parameters[0] + ): ProjectGroup { + return this.getProjectGroupOperations().createProjectGroup(input) + } + + updateProjectGroup( + groupId: string, + updates: Parameters[1] + ): ProjectGroup | null { + return this.getProjectGroupOperations().updateProjectGroup(groupId, updates) + } + + deleteProjectGroup(groupId: string): boolean { + return this.getProjectGroupOperations().deleteProjectGroup(groupId) + } + + private folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null + + private getFolderWorkspaceOperations(): FolderWorkspacePersistenceOperations { + this.folderWorkspaceOperations ??= new FolderWorkspacePersistenceOperations({ + state: this.state, + scheduleSave: () => this.scheduleSave(), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), + pruneMobileClientTabSelections: (matchesWorktreeId) => + this.pruneMobileClientTabSelections(matchesWorktreeId), + hydrateRepo: (repo) => this.hydrateRepo(repo) + }) + return this.folderWorkspaceOperations + } + + getFolderWorkspaces(): FolderWorkspace[] { + return this.getFolderWorkspaceOperations().getFolderWorkspaces() + } + + getFolderWorkspace(id: string): FolderWorkspace | undefined { + return this.getFolderWorkspaceOperations().getFolderWorkspace(id) + } + + createFolderWorkspace( + input: Parameters[0] + ): FolderWorkspace { + return this.getFolderWorkspaceOperations().createFolderWorkspace(input) + } + + updateFolderWorkspace( + id: string, + updates: Parameters[1] + ): FolderWorkspace | null { + return this.getFolderWorkspaceOperations().updateFolderWorkspace(id, updates) + } + + removeFolderWorkspace(id: string): boolean { + return this.getFolderWorkspaceOperations().removeFolderWorkspace(id) + } + + moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { + return this.getFolderWorkspaceOperations().moveProjectToGroup(repoId, groupId, order) + } + + private repoOrderOperations: RepoOrderPersistenceOperations | null = null + + private getRepoOrderOperations(): RepoOrderPersistenceOperations { + this.repoOrderOperations ??= new RepoOrderPersistenceOperations({ + state: this.state, + syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), + scheduleSave: () => this.scheduleSave() + }) + return this.repoOrderOperations + } + + addRepo(repo: Repo): void { + this.getRepoOrderOperations().addRepo(repo) + } + + reorderRepos(orderedIds: string[]): boolean { + return this.getRepoOrderOperations().reorderRepos(orderedIds) + } + + reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { + return this.getRepoOrderOperations().reorderReposForHost(orderedIds, hostId) + } + + removeProject(id: string): void { + this.state.repos = this.state.repos.filter((r) => r.id !== id) + this.syncProjectHostSetupCompatibilityState() + // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. + delete this.state.sparsePresetsByRepo[id] + delete this.state.retiredWorktreeNamesByRepo?.[id] + this.pruneWorktreeStateForRepo(id, null) + this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) + this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + this.state.workspaceSessionsByHostId, + id + ) + this.scheduleSave() + } + + // Why: the same repo id can exist on multiple execution hosts; remove only this host's row and metadata, never another host's. + removeProjectForHost(id: string, hostId: ExecutionHostId): void { + this.state.repos = this.state.repos.filter( + (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) + ) + const idStillPresent = this.state.repos.some((r) => r.id === id) + // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. + if (!idStillPresent) { + delete this.state.sparsePresetsByRepo[id] + delete this.state.retiredWorktreeNamesByRepo?.[id] + } + this.syncProjectHostSetupCompatibilityState() + // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). + this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) + if (!idStillPresent) { + this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) + this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + this.state.workspaceSessionsByHostId, + id + ) + } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { + const session = this.state.workspaceSessionsByHostId?.[hostId] + if (session) { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [hostId]: removeRepoFromWorkspaceSession(session, id) + } + } + } + this.scheduleSave() + } + + // Prune worktree meta/lineage for a repo id; hostId null prunes all entries, else only that host's (missing meta.hostId = local). + private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { + pruneWorktreeStateForRepoOperation(this.state, id, hostId, (matchesWorktreeId) => + this.pruneMobileClientTabSelections(matchesWorktreeId) + ) + } + + private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + this.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const worktreeId of Object.keys(selectionsByWorktree)) { + if (matchesWorktreeId(worktreeId)) { + delete selectionsByWorktree[worktreeId] + } + } + if (Object.keys(selectionsByWorktree).length === 0) { + delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] + } + } + } + + private repoUpdateOperations: RepoUpdatePersistenceOperations | null = null + + private getRepoUpdateOperations(): RepoUpdatePersistenceOperations { + this.repoUpdateOperations ??= new RepoUpdatePersistenceOperations({ + state: this.state, + syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), + scheduleSave: () => this.scheduleSave(), + hydrateRepo: (repo) => this.hydrateRepo(repo) + }) + return this.repoUpdateOperations + } + + updateRepo( + id: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'gitRemoteIdentity' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'executionHostId' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'forkSyncMode' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + | 'projectHostSetupMethod' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + }, + hostId?: ExecutionHostId + ): Repo | null { + return this.getRepoUpdateOperations().updateRepo(id, updates, hostId) + } + + private syncProjectHostSetupCompatibilityState(): void { + const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) + this.state.projects = compatibilityState.projects + this.state.projectHostSetups = compatibilityState.projectHostSetups + } + + private projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null + + private getProjectHostSetupOperations(): ProjectHostSetupPersistenceOperations { + this.projectHostSetupOperations ??= new ProjectHostSetupPersistenceOperations({ + state: this.state, + updateRepo: (id, updates, hostId) => this.updateRepo(id, updates, hostId), + scheduleSave: () => this.scheduleSave() + }) + return this.projectHostSetupOperations + } + + private updateRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] + ): { setup: ProjectHostSetup; repo: Repo } | null { + return this.getProjectHostSetupOperations().updateRepoBackedProjectHostSetup( + setup, + repo, + updates + ) + } + + private updateIndependentProjectHostSetup( + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] + ): ProjectHostSetup { + return this.getProjectHostSetupOperations().updateIndependentProjectHostSetup(setup, updates) + } + + private hydrateRepo(repo: Repo): Repo { + return hydrateRepoOperation(repo, this.gitUsernameCache) + } + + // ── Sparse Presets ───────────────────────────────────────────────── + + // ── Mobile client tab selections ────────────────────────────────── + + getMobileClientTabSelections(): PersistedMobileClientTabSelections { + return this.state.mobileClientTabSelectionsByDeviceId ?? {} + } + + setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { + this.state.mobileClientTabSelectionsByDeviceId = next + this.scheduleSave() + } + + 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 ─────────────────────────────────────────────────── + + private getAutomationDefinitionOperations(): AutomationDefinitionOperations { + return { + state: this.state, + flush: () => this.flush(), + recordCreated: () => this.recordFeatureInteraction('automation-created') + } + } + + private getAutomationRunOperations(): AutomationRunOperations { + return { + state: this.state, + flush: () => this.flush(), + recordManualRun: () => this.recordFeatureInteraction('automation-run'), + getWorkspaceDisplayName: (workspaceId) => + this.getAutomationRunWorkspaceDisplayName(workspaceId) + } + } + + listAutomations(): Automation[] { + return listAutomationsOperation(this.state) + } + + listAutomationRuns(automationId?: string): AutomationRun[] { + return listAutomationRunsOperation(this.state, automationId) + } + + createAutomation(input: AutomationCreateInput): Automation { + return createAutomationOperation(this.getAutomationDefinitionOperations(), input) + } + + updateAutomation(id: string, updates: AutomationUpdateInput): Automation { + return updateAutomationOperation(this.getAutomationDefinitionOperations(), id, updates) + } + + deleteAutomation(id: string): void { + deleteAutomationOperation(this.getAutomationDefinitionOperations(), id) + } + + createAutomationRun( + automation: Automation, + scheduledFor: number, + trigger: AutomationRunTrigger = 'scheduled' + ): AutomationRun { + return createAutomationRunOperation( + this.getAutomationRunOperations(), + automation, + scheduledFor, + trigger + ) + } + + updateAutomationRun(result: AutomationDispatchResult): AutomationRun { + return updateAutomationRunOperation(this.getAutomationRunOperations(), result) + } + + snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { + return snapshotAutomationRunWorkspaceDisplayNameOperation( + this.getAutomationRunOperations(), + workspaceId, + displayName + ) + } + + 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 { + return advanceAutomationNextRunOperation(this.state, () => this.flush(), id, now) + } + + getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { + return getLatestAutomationOccurrenceOperation(automation, now) + } + + // ── Worktree Meta ────────────────────────────────────────────────── + + getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { + return this.state.worktreeMeta[worktreeId] + } + + getAllWorktreeMeta(): Record { + return this.state.worktreeMeta + } + + setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { + const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() + const updated = { ...existing, ...meta } + updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) + const linkedTaskSourceContext = normalizeStoredTaskSourceContext( + updated.linkedTaskSourceContext + ) + updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + updated.linkedWorkItem, + linkedTaskSourceContext + ) + ? linkedTaskSourceContext + : null + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } + this.state.worktreeMeta[worktreeId] = updated + this.scheduleSave() + return updated + } + + removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { + // Persisted ownership beats stale live routing; hostId is only an ownerless fallback. + const owner = this.state.worktreeMeta[worktreeId]?.hostId ?? hostId + // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. + const partitions = new Set( + workspaceSessionPartitionIdsForHost(owner).filter((partition) => + this.hasPersistedWorkspaceSession(partition) + ) + ) + // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill + // partition that never held this worktree has no claim on the repo at all. + const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) + const fencedPartitions = new Set( + [...partitions].filter( + (partition) => + this.partitionOwnsWorktreeTabs(worktreeId, partition) || + (partition === ownerPartition && + !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) + ) + ) + delete this.state.worktreeMeta[worktreeId] + delete this.state.worktreeLineageById[worktreeId] + delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] + for (const partition of partitions) { + this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { + advanceTerminalTopologyRevision: fencedPartitions.has(partition) + }) + } + this.scheduleSave() + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + 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() + } + + /** + * Re-key every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId` after the worktree folder (and its + * `${repoId}::${path}` id) was renamed on disk, so a refresh re-binds state instead of orphaning it. Records the old id on + * the new meta's `priorWorktreeIds` so session GC/hydration still recognizes PTY sessions minted under it. No-op when ids match. + * Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts. + */ + migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { + if (migrateWorktreeIdentityOperation(this.state, oldWorktreeId, newWorktreeId)) { + this.scheduleSave() + } + } + + getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { + return this.state.workspaceLineageByChildKey[childWorkspaceKey] + } + + getAllWorkspaceLineage(): Record { + return this.state.workspaceLineageByChildKey + } + + setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { + this.state.workspaceLineageByChildKey[lineage.childWorkspaceKey] = lineage + this.scheduleSave() + return lineage + } + + removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { + delete this.state.workspaceLineageByChildKey[childWorkspaceKey] + this.scheduleSave() + } + + private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { + const parentKey = folderWorkspaceKey(folderWorkspaceId) + for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { + if (lineage.parentWorkspaceKey === parentKey) { + delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] + } + } + } + + // ── Settings ─────────────────────────────────────────────────────── + + getSettings(): GlobalSettings { + return this.state.settings + } + + onSettingsChanged( + listener: ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + ): () => void { + this.settingsChangeListeners.add(listener) + return () => { + this.settingsChangeListeners.delete(listener) + } + } + + private notifySettingsChanged( + updates: Partial, + originWebContentsId?: number + ): void { + for (const listener of this.settingsChangeListeners) { + listener(updates, this.state.settings, originWebContentsId) + } + } + + // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. + onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { + this.uiChangeListeners.add(listener) + return () => { + this.uiChangeListeners.delete(listener) + } + } + + private notifyUIChanged(): void { + if (this.uiChangeListeners.size === 0) { + return + } + const ui = this.getUI() + for (const listener of this.uiChangeListeners) { + listener(ui) + } + } + + private getSettingsMutationOperations(): SettingsMutationOperations { + return { + state: this.state, + removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), + scheduleSave: () => this.scheduleSave(), + notifySettingsChanged: (updates, originWebContentsId) => + this.notifySettingsChanged(updates, originWebContentsId) + } + } + + updateSettings( + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} + ): GlobalSettings { + return updateSettingsOperation(this.getSettingsMutationOperations(), updates, options) + } + + // ── UI State ─────────────────────────────────────────────────────── + + private getUIUpdateOperations(): UIUpdateOperations { + return { + state: this.state, + removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), + setActiveView: (activeView) => this.activeViewPreference.set(activeView), + getUI: () => this.getUI(), + scheduleSave: () => this.scheduleSave(), + notifyUIChanged: () => this.notifyUIChanged() + } + } + + getUI(): PersistedState['ui'] { + return getPersistedUI(this.state, this.activeViewPreference.get()) + } + + updateUI(updates: Partial): void { + updatePersistedUI(this.getUIUpdateOperations(), updates) + } + + private getFeatureInteractionOperations(): FeatureInteractionOperations { + return { + state: this.state, + scheduleSave: () => this.scheduleSave(), + notifyUIChanged: () => this.notifyUIChanged(), + getUI: () => this.getUI() + } + } + + recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { + return recordFeatureInteractionOperation(this.getFeatureInteractionOperations(), id) + } + + // ── Onboarding ──────────────────────────────────────────────────── + + getOnboarding(): PersistedState['onboarding'] { + const defaults = getDefaultOnboardingState() + return { + ...defaults, + ...this.state.onboarding, + checklist: { + ...defaults.checklist, + ...this.state.onboarding?.checklist + } + } + } + + updateOnboarding( + updates: Partial> & { + checklist?: Partial + } + ): 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 { + // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. + this.state.githubCache = cache + this.githubCacheDirty = true + this.githubCacheGeneration += 1 + } + + // ── Workspace Session ───────────────────────────────────────────── + + /** Resolve an execution host argument to a canonical id; unknown/empty falls back to 'local' for legacy callers. */ + private resolveHostId(hostId?: string | null): ExecutionHostId { + return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + } + + getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + return this.state.workspaceSession ?? getDefaultWorkspaceSession() + } + return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() + } + + /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ + private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { + return ( + hostId === LOCAL_EXECUTION_HOST_ID || + this.state.workspaceSessionsByHostId?.[hostId] !== undefined + ) + } + + getWorkspaceSessionHostIds(): ExecutionHostId[] { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { + const hostId = normalizeExecutionHostId(key) + if (hostId) { + hostIds.add(hostId) + } + } + return [...hostIds] + } + + readTerminalScrollbackSnapshot(ref: string): string | null { + return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) + } + + /** Resolve the worktree a terminal tab belongs to; more reliable than agent-echoed hook fields. */ + getWorktreeIdForTab(tabId: string): string | undefined { + return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) + } + + setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.setLocalWorkspaceSession(session) + return + } + this.setHostWorkspaceSession(resolved, session) + } + + removeWorkspaceSessionStateForWorktree( + worktreeId: string, + hostId?: ExecutionHostId | null, + options: { advanceTerminalTopologyRevision?: boolean } = {} + ): void { + for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { + this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) + } + } + + private removeWorkspaceSessionOwnerInPartition( + worktreeId: string, + resolved: ExecutionHostId, + options: { advanceTerminalTopologyRevision?: boolean } + ): void { + if (!this.hasPersistedWorkspaceSession(resolved)) { + return + } + const current = this.getWorkspaceSession(resolved) + const session = removeWorkspaceSessionOwner(current, worktreeId, { + advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true + }) + if (!session) { + return + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSession = session + } else { + // Host scoping matters because identical repo/path ids may exist on two servers. + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolved]: session + } + } + this.scheduleSave() + } + + /** Whether a partition still holds terminal membership for `worktreeId`. */ + private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { + return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined + } + + /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ + private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} + return Object.entries(tabsByWorktree).some( + ([id, tabs]) => + id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 + ) + } + + stageWorkspaceSessionBeforeUnload( + session: PersistedState['workspaceSession'], + hostId?: string | null + ): void { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.setLocalWorkspaceSession(session, true) + return + } + this.setHostWorkspaceSession(resolved, session) + } + + /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ + private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { + // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. + session = sanitizeWorkspaceSessionTerminalRetirements( + session, + this.state.workspaceSessionsByHostId?.[hostId] + ) + const pruned = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, this.state.repos) + ) + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [hostId]: pruned + } + this.scheduleSave() + } + + private setLocalWorkspaceSession( + session: PersistedState['workspaceSession'], + deferSnapshotFiles = false + ): void { + const prior = this.state.workspaceSession + session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) + session = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, this.state.repos) + ) + + // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. + 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) { + registerPersistedPaneKeyAlias(entry) + } + 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() + 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 may 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 write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. + const buffersByLeafId = preserveMissingLeafRecordEntries( + priorLayout.buffersByLeafId, + layout.buffersByLeafId, + liveLeafIds + ) + const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( + priorLayout.scrollbackRefsByLeafId, + layout.scrollbackRefsByLeafId, + liveLeafIds + ) + const titlesByLeafId = preserveMissingLeafRecordEntries( + priorLayout.titlesByLeafId, + layout.titlesByLeafId, + liveLeafIds + ) + if (buffersByLeafId) { + layout.buffersByLeafId = buffersByLeafId + } + if (scrollbackRefsByLeafId) { + layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId + } + if (titlesByLeafId) { + layout.titlesByLeafId = titlesByLeafId + } + } + } + } + session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) + if (!deferSnapshotFiles) { + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + this.terminalScrollbackSnapshotStorage + ) + session = migratedScrollback.session + deleteRemovedTerminalScrollbackSnapshots( + prior, + session, + this.terminalScrollbackSnapshotStorage + ) + } + this.state.workspaceSession = session + if (deferSnapshotFiles) { + this.enqueueTerminalScrollbackSnapshotWork(prior, session) + } + this.scheduleSave() + } + + private enqueueTerminalScrollbackSnapshotWork( + prior: WorkspaceSessionState | undefined, + staged: WorkspaceSessionState + ): void { + const previous = this.pendingSnapshotFileWork ?? Promise.resolve() + const work = previous + .then(async () => { + if (this.state.workspaceSession !== staged) { + if (this.state.workspaceSession) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + this.state.workspaceSession, + this.terminalScrollbackSnapshotStorage + ) + } + return + } + const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( + staged, + this.terminalScrollbackSnapshotStorage + ) + const current = + this.state.workspaceSession === staged ? migrated : this.state.workspaceSession + if (this.state.workspaceSession === staged) { + this.state.workspaceSession = migrated + } else if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + migrated, + current, + this.terminalScrollbackSnapshotStorage + ) + } + if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + current, + this.terminalScrollbackSnapshotStorage + ) + } + }) + .catch((error) => { + console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) + }) + .finally(() => { + if (this.pendingSnapshotFileWork === work) { + this.pendingSnapshotFileWork = null + } + }) + this.pendingSnapshotFileWork = work + } + + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { + const resolved = this.resolveHostId(hostId) + // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. + let next: WorkspaceSessionState = { + ...this.getWorkspaceSession(resolved), + ...patch + } + if (workspaceSessionPatchNeedsFullNormalization(patch)) { + this.setWorkspaceSession(next, resolved) + return + } + if (Object.hasOwn(patch, 'browserUrlHistory')) { + next = pruneWorkspaceSessionBrowserHistory(next) + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSession = next + } else { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolved]: next + } + } + this.scheduleSave() + } + + private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { + const leafIds = new Set() + 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 getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { + try { + return toRelaySshPtyId(targetId, ptyId) + } catch { + return ptyId + } + } + + private getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { + return toRelaySshPtyId(targetId, ptyId) + } + + private sshRemotePtyLeaseMatchesBinding( + lease: SshRemotePtyLease, + binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + } + ): boolean { + const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) + if (lease.ptyId !== bindingPtyId) { + return false + } + // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't 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 getConnectionIdForWorktree(worktreeId: string): string | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return this.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null + } + + // Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). + persistPtyBinding( + args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } + expectedSourceBinding?: PtyBindingSourceExpectation + }, + hostId?: string | null + ): boolean { + const resolvedHostId = this.resolveHostId(hostId) + const session = this.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + if (args.expectedSourceBinding) { + const expected = args.expectedSourceBinding + if (expected.tabId !== args.tabId) { + return false + } + const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId + ) + const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] + const sourcePaneKey = `${expected.tabId}:${expected.leafId}` + if ( + !sourceTab || + sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || + !layoutContainsLeafId(sourceLayout.root, expected.leafId) || + (expected.incarnationId !== undefined && + session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) + ) { + return false + } + } + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return false + } + } + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolvedHostId]: session + } + } + const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + const establishesSplitAuthority = args.expectedSourceBinding !== undefined + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesSplitAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + const restoreSession = (): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSession = sessionBeforeBinding + } else { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolvedHostId]: sessionBeforeBinding + } + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = args.ptyId + } else { + terminalMembershipChanged = true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + try { + this.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + 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 { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() + try { + this.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } + + // ── SSH Targets ──────────────────────────────────────────────────── + + private getSshTargetStateOperations(): SshTargetStateOperations { + return { + state: this.state, + protectedSecrets: this.protectedSecrets, + scheduleSave: () => this.scheduleSave(), + flush: () => this.flush() + } + } + + getSshTargets(): SshTarget[] { + return getSshTargetsOperation(this.state) + } + + getSshTarget(id: string): SshTarget | undefined { + return getSshTargetOperation(this.state, id) + } + + addSshTarget(target: SshTarget): void { + addSshTargetOperation(this.getSshTargetStateOperations(), target) + } + + updateSshTarget(id: string, updates: Partial>): SshTarget | null { + return updateSshTargetOperation(this.getSshTargetStateOperations(), id, updates) + } + + removeSshTarget(id: string): void { + removeSshTargetOperation(this.getSshTargetStateOperations(), id) + } + + // ── Live Claude PTY sessions ─────────────────────────────────────── + + getClaudeLivePtySessionIds(): string[] { + return getClaudeLivePtySessionIdsOperation(this.state) + } + + addClaudeLivePtySessionId(sessionId: string): void { + addClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) + } + + removeClaudeLivePtySessionId(sessionId: string): void { + removeClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) + } + + getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { + const stored = this.state.retiredWorktreeNamesByRepo?.[repoId] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { + const stored = this.state.retiredWorktreeNamesByNamespace?.[namespaceKey] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + addRetiredWorktreeName(repoId: string, name: string): void { + const normalized = normalizeRetirableGeneratedName(name) + if (!repoId || !normalized) { + return + } + this.applyRetiredWorktreeNames(repoId, [normalized]) + } + + mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { + if (!repoId) { + return false + } + const incoming = new Set() + for (const name of names) { + const normalized = normalizeRetirableGeneratedName(name) + if (normalized) { + incoming.add(normalized) + } + } + return incoming.size > 0 && this.applyRetiredWorktreeNames(repoId, incoming) + } + + mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { + if (!namespaceKey) { + return false + } + const normalized = new Set() + for (const name of names) { + const candidate = normalizeRetirableGeneratedName(name) + if (candidate) { + normalized.add(candidate) + } + } + const next = addRetiredNames( + this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), + normalized + ) + if (!next) { + return false + } + this.state.retiredWorktreeNamesByNamespace ??= {} + this.state.retiredWorktreeNamesByNamespace[namespaceKey] = next + this.scheduleSave() + return true + } + + private applyRetiredWorktreeNames(repoId: string, names: Iterable): boolean { + const next = addRetiredNames(this.getRetiredWorktreeNameRegistry(repoId), names) + if (!next) { + return false + } + this.state.retiredWorktreeNamesByRepo ??= {} + this.state.retiredWorktreeNamesByRepo[repoId] = next + this.scheduleSave() + return true + } + + getDeletedSshConfigAliases(): string[] { + return getDeletedSshConfigAliasesOperation(this.state) + } + + addDeletedSshConfigAlias(alias: string): void { + addDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) + } + + removeDeletedSshConfigAlias(alias: string): void { + removeDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) + } + + clearDeletedSshConfigAliases(): void { + clearDeletedSshConfigAliasesOperation(this.getSshTargetStateOperations()) + } + + getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { + return getRemovedSshTargetTombstonesOperation(this.state) + } + + addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { + addRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), tombstone) + } + + removeRemovedSshTargetTombstone(oldTargetId: string): void { + removeRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), oldTargetId) + } + + /** + * Re-point every repo and worktree meta pinned to a removed SSH target id onto + * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. + */ + reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { + const operations: SshTargetReassignmentOperations = { + state: this.state, + protectedSecrets: this.protectedSecrets, + syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), + scheduleSave: () => this.scheduleSave() + } + return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) + } + + // ── SSH PTY Consumer Recovery ────────────────────────────────────── + + private getSshPtyConsumerRecoveryOperations(): SshPtyConsumerRecoveryOperations { + return { + state: this.state, + protectedSecrets: this.protectedSecrets, + flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() + } + } + + getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { + return getSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), targetId) + } + + async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { + await upsertSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), record) + } + + async removeSshPtyConsumerRecovery(targetId: string): Promise { + await removeSshPtyConsumerRecoveryOperation( + this.getSshPtyConsumerRecoveryOperations(), + targetId + ) + } + + // ── SSH Remote PTY Leases ────────────────────────────────────────── + + private getSshPtyBindingCleanupOperations(): SshPtyBindingCleanupOperations { + return { + state: this.state, + toComparablePtyId: (targetId, ptyId) => + this.getRelayPtyIdForSshLeaseComparison(targetId, ptyId), + scheduleSave: () => this.scheduleSave() + } + } + + private getSshPtyLeaseOperations(): SshPtyLeaseOperations { + return { + state: this.state, + toStoredPtyId: (targetId, ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId), + clearBindingsForTarget: (targetId) => + clearSshRemotePtyBindingsForTargetOperation( + this.getSshPtyBindingCleanupOperations(), + targetId + ), + clearBindingsForLeases: (targetId, leases) => + clearSshRemotePtyBindingsForLeasesOperation( + this.getSshPtyBindingCleanupOperations(), + targetId, + leases + ), + flush: () => this.flush(), + flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() + } + } + + getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { + return getSshRemotePtyLeasesOperation(this.state, targetId) + } + + upsertSshRemotePtyLease( + lease: Omit & + Partial> + ): void { + upsertSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), lease) + } + + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId, state) + } + + // Why no write of its own: the committed quit path calls this immediately before the final store + // flush, and that flush is what persists it. A durable write here would race the flush and be + // rejected the moment it latches, which is exactly how an attached lease used to survive quit. + markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesForShutdownOperation(this.getSshPtyLeaseOperations(), targetId, state) + } + + async markSshRemotePtyLeasesAsync( + targetId: string, + state: SshRemotePtyLease['state'] + ): Promise { + await markSshRemotePtyLeasesAsyncOperation(this.getSshPtyLeaseOperations(), targetId, state) + } + + async markSshRemotePtyLeasesAttachedAsync( + targetId: string, + ptyIds: readonly string[] + ): Promise { + await markSshRemotePtyLeasesAttachedAsyncOperation( + this.getSshPtyLeaseOperations(), + targetId, + ptyIds + ) + } + + markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId, state) + } + + removeSshRemotePtyLease(targetId: string, ptyId: string): void { + removeSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId) + } + + removeSshRemotePtyLeases(targetId: string): void { + removeSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId) + } + + // ── Flush (for shutdown) ─────────────────────────────────────────── + + flush(): void { + if (this.quitFlushStarted) { + return + } + try { + this.flushOrThrow() + } catch (err) { + console.error('[persistence] Failed to flush state:', err) + } + try { + this.flushActiveViewPreferenceOrThrow() + } catch (err) { + console.error('[active-view] Failed to flush preference:', err) + } + this.writeGithubCacheSnapshotSync() + } + + /** + * Async twin of flush() for the quit path. + * + * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron + * main thread. On a stalled network profile mount that syscall is uninterruptible, so the + * app stops repainting and Force Quit stops working — and no main-thread deadline can + * bound it, because the deadline's own timer is stuck behind the same block. + * + * Never throws — it joins the quit teardown barrier, where a rejection is noise. + */ + flushAsync(): Promise { + if (this.quitFlushPromise) { + return this.quitFlushPromise + } + this.quitFlushStarted = true + this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) + return this.quitFlushPromise + } + + flushPendingAsync(): Promise { + // Best-effort callers must not livelock while the live app keeps mutating state. + return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) + } + + flushPendingOrThrowAsync( + options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} + ): Promise { + if (this.writesFrozen || this.quitFlushStarted) { + return Promise.reject(new Error('Cannot flush while persistence is finalized')) + } + return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) + } + + // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are + // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. + private async flushDurableStateOrThrowAsync(): Promise { + if (this.writesFrozen || this.quitFlushStarted) { + throw new Error('Cannot flush while persistence is finalized') + } + for (;;) { + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + this.firstPendingSaveAt = null + const generation = this.writeGeneration + await this.enqueueWrite() + if (generation === this.writeGeneration) { + break + } + } + } + + private async flushCurrentStateAsync( + final: boolean, + signal?: AbortSignal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false + ): Promise { + const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + this.firstPendingSaveAt = null + const generation = this.writeGeneration + try { + await this.enqueueWrite() + } catch (error) { + await (final + ? this.activeViewPreference.flushAsync() + : this.activeViewPreference.flushPendingAsync(signal)) + await this.writeGithubCacheSnapshotAsync(final, signal) + throw error + } + await (final + ? this.activeViewPreference.flushAsync() + : this.activeViewPreference.flushPendingAsync(signal)) + await this.writeGithubCacheSnapshotAsync(final, signal) + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + this.lastDurableWriteGeneration >= requiredDurableGeneration + ) { + break + } + continue + } + if (generation === this.writeGeneration) { + break + } + } + } + + // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. + private async writeGithubCacheSnapshotAsync( + drainToStableGeneration = true, + signal?: AbortSignal + ): Promise { + if (!this.githubCacheDirty) { + return + } + const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup + const nextWrite = previousWrite + .then(async () => { + while (this.githubCacheDirty) { + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + const generation = this.githubCacheGeneration + const cacheFile = getGithubCacheFile(this.dataFile) + const tmpFile = durableWriteTempPath(cacheFile) + let renamed = false + try { + await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') + if (generation === this.githubCacheGeneration) { + await rename(tmpFile, cacheFile) + renamed = true + if (generation === this.githubCacheGeneration) { + this.githubCacheDirty = false + } + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + if (!drainToStableGeneration) { + break + } + } + }) + .catch((err) => { + console.warn('[persistence] Failed to write github cache snapshot:', err) + }) + .finally(() => { + if (this.pendingGithubCacheWrite === nextWrite) { + this.pendingGithubCacheWrite = null + } + }) + this.pendingGithubCacheWrite = nextWrite + await nextWrite + } + + // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. + freezeWrites(): void { + this.writesFrozen = true + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + } + + // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. + private writeGithubCacheSnapshotSync(): void { + if (!this.githubCacheDirty) { + return + } + if (this.pendingGithubCacheWrite) { + void this.writeGithubCacheSnapshotAsync() + return + } + const cacheFile = getGithubCacheFile(this.dataFile) + const generation = this.githubCacheGeneration + const tmpFile = durableWriteTempPath(cacheFile) + try { + writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') + renameSync(tmpFile, cacheFile) + if (generation === this.githubCacheGeneration) { + this.githubCacheDirty = false + } + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } + } +} + +function getDefaultWorktreeMeta(): WorktreeMeta { + return { + instanceId: randomUUID(), + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: Date.now(), + lastActivityAt: 0, + workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID + } +} diff --git a/src/main/persistence/loading-store/user-data-path.ts b/src/main/persistence/loading-store/user-data-path.ts new file mode 100644 index 00000000000..e6b11b85cfa --- /dev/null +++ b/src/main/persistence/loading-store/user-data-path.ts @@ -0,0 +1,108 @@ +import { app } from 'electron' +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { hardenExistingSecureFile } from '../../../shared/secure-file' +import { MOBILE_PAIRING_USERDATA_FILES } from '../../runtime/mobile-pairing-files' + +// Why capture once (not a module const, not per-call): a const resolves before configureDevUserDataPath() redirects userData (dev/prod collide); +// per-call resolves after app.setName('Orca') flips path case and loses data on case-sensitive FS. index.ts calls initDataPath() at the right moment. +let _dataFile: string | null = null +let _userDataDir: string | null = null + +export function initDataPath(): void { + const userDataDir = app.getPath('userData') + _userDataDir = userDataDir + _dataFile = join(userDataDir, 'orca-data.json') +} + +export function getDataFile(): string { + if (!_dataFile) { + // Safety fallback — should not be hit in normal startup. + const userDataDir = app.getPath('userData') + _userDataDir = userDataDir + _dataFile = join(userDataDir, 'orca-data.json') + } + return _dataFile +} + +// Why a sidecar: githubCache refreshes every poll and would rewrite the whole multi-MB orca-data.json each cycle. +// Snapshotted best-effort at quit for instant badges next launch; safe to lose. +export function getGithubCacheFile(dataFile = getDataFile()): string { + return join(dirname(dataFile), 'orca-github-cache.json') +} + +export function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { + try { + const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown + const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + if ( + isPlainRecord(parsed) && + isPlainRecord((parsed as { pr?: unknown }).pr) && + isPlainRecord((parsed as { issue?: unknown }).issue) + ) { + return parsed as PersistedState['githubCache'] + } + } catch { + // Missing or corrupt snapshot: start with an empty cache and refetch. + } + return null +} + +/** + * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves. + * + * Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices. + */ +export function getCanonicalUserDataPath(): string { + if (!_userDataDir) { + // Safety fallback — should not be hit in normal startup. + _userDataDir = app.getPath('userData') + } + return _userDataDir +} + +/** + * Copy legacy mobile pairing credentials into the canonical userData directory. + * + * Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key. + */ +export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { + const targetUserDataDir = getCanonicalUserDataPath() + if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) { + return + } + + const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({ + sourcePath: join(sourceUserDataDir, fileName), + targetPath: join(targetUserDataDir, fileName) + })) + if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) { + return + } + if (migrations.some(({ targetPath }) => existsSync(targetPath))) { + return + } + + mkdirSync(targetUserDataDir, { recursive: true }) + const copied: string[] = [] + try { + for (const { sourcePath, targetPath } of migrations) { + copyFileSync(sourcePath, targetPath) + copied.push(targetPath) + // Why: copyFileSync drops Windows ACLs, so re-assert current-user-only on these credential copies (device tokens, E2EE key). + hardenExistingSecureFile(targetPath) + } + } catch (error) { + // Why: a half-copied pair mixes devices with the wrong key, and the existing-target guard above would block the retry. + for (const targetPath of copied) { + try { + rmSync(targetPath, { force: true }) + } catch { + // Best effort — leave the retry guard to the next launch. + } + } + console.error('[persistence] Failed to migrate mobile pairing files forward:', error) + } +} diff --git a/src/main/persistence/restoring-sessions/floating-workspace-normalization.ts b/src/main/persistence/restoring-sessions/floating-workspace-normalization.ts new file mode 100644 index 00000000000..593812eb97c --- /dev/null +++ b/src/main/persistence/restoring-sessions/floating-workspace-normalization.ts @@ -0,0 +1,72 @@ +import { realpathSync, statSync } from 'node:fs' +import { isAbsolute, join, resolve, sep } from 'node:path' + +export function expandFloatingWorkspaceHomePath(input: string, home: string): string { + if (input === '~') { + return home + } + if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) { + return join(home, input.slice(2)) + } + return input +} + +export function resolveFloatingWorkspacePath(input: string, home: string): string { + const expanded = expandFloatingWorkspaceHomePath(input, home) + return isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded) +} + +export function canonicalizePersistedFloatingWorkspaceDirectory( + input: string, + home: string +): string | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + try { + const canonicalPath = resolve(realpathSync(resolveFloatingWorkspacePath(trimmed, home))) + return statSync(canonicalPath).isDirectory() ? canonicalPath : null + } catch { + return null + } +} + +export function normalizeFloatingWorkspaceTrustedCwds( + input: unknown, + home: string +): { trustedCwds: string[]; changed: boolean } { + const rawTrustedCwds = Array.isArray(input) ? input : [] + const trustedCwds: string[] = [] + const seen = new Set() + let changed = input !== undefined && !Array.isArray(input) + + for (const rawTrustedCwd of rawTrustedCwds) { + if (typeof rawTrustedCwd !== 'string') { + changed = true + continue + } + const trimmedTrustedCwd = rawTrustedCwd.trim() + if (!trimmedTrustedCwd) { + changed = true + continue + } + const canonicalPath = canonicalizePersistedFloatingWorkspaceDirectory(trimmedTrustedCwd, home) + const normalizedPath = canonicalPath ?? resolveFloatingWorkspacePath(trimmedTrustedCwd, home) + if (!normalizedPath) { + changed = true + continue + } + if (seen.has(normalizedPath)) { + changed = true + continue + } + seen.add(normalizedPath) + trustedCwds.push(normalizedPath) + if (rawTrustedCwd !== normalizedPath) { + changed = true + } + } + + return { trustedCwds, changed } +} diff --git a/src/main/persistence/restoring-sessions/folder-scope-migration.ts b/src/main/persistence/restoring-sessions/folder-scope-migration.ts new file mode 100644 index 00000000000..74ca7c98df6 --- /dev/null +++ b/src/main/persistence/restoring-sessions/folder-scope-migration.ts @@ -0,0 +1,86 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { getProjectGroupSubtreeIds } from '../../../shared/project-groups' + +export function inferFolderScopeConnectionIdForMigration(args: { + folderPath: string + projectGroupId: string + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): string | null { + const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const candidateRepos = + groupRepos.length > 0 + ? groupRepos + : args.repos.filter((repo) => isPathInsideOrEqual(args.folderPath, repo.path)) + if (candidateRepos.length === 0) { + return null + } + let hasLocalRepo = false + const connectionIds = new Set() + for (const repo of candidateRepos) { + if (repo.connectionId) { + connectionIds.add(repo.connectionId) + } else { + hasLocalRepo = true + } + } + if (hasLocalRepo || connectionIds.size !== 1) { + return null + } + return [...connectionIds][0] +} + +export function backfillFolderScopeConnectionIds(state: PersistedState): { + state: PersistedState + changed: boolean +} { + const groups = state.projectGroups ?? [] + const repos = state.repos ?? [] + let changed = false + const projectGroups = groups.map((group) => { + if (group.connectionId || !group.parentPath) { + return group + } + const connectionId = inferFolderScopeConnectionIdForMigration({ + folderPath: group.parentPath, + projectGroupId: group.id, + projectGroups: groups, + repos + }) + if (!connectionId) { + return group + } + changed = true + return { ...group, connectionId } + }) + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => { + if (workspace.connectionId) { + return workspace + } + const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null + const connectionId = + groupConnectionId ?? + inferFolderScopeConnectionIdForMigration({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + projectGroups, + repos + }) + if (!connectionId) { + return workspace + } + changed = true + return { ...workspace, connectionId } + }) + return { + changed, + state: changed ? { ...state, projectGroups, folderWorkspaces } : state + } +} diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts new file mode 100644 index 00000000000..05783b7b5c2 --- /dev/null +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -0,0 +1,246 @@ +import { randomUUID } from 'node:crypto' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import { normalizeFolderWorkspaceName } from '../../../shared/folder-workspaces' +import { getNextProjectGroupOrder } from '../../../shared/project-groups' +import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' +import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { removeWorkspaceSessionOwner } from './session-owner-removal' + +export type FolderWorkspaceMutationOperations = { + state: StoreOwnedPersistedState + scheduleSave: () => void + removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void + pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void + hydrateRepo: (repo: Repo) => Repo +} + +export class FolderWorkspacePersistenceOperations { + constructor(private readonly operations: FolderWorkspaceMutationOperations) {} + + private get state(): PersistedState { + return this.operations.state + } + + private scheduleSave(): void { + this.operations.scheduleSave() + } + + private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { + this.operations.removeWorkspaceLineageForFolderParent(folderWorkspaceId) + } + + private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { + this.operations.pruneMobileClientTabSelections(matchesWorktreeId) + } + + private hydrateRepo(repo: Repo): Repo { + return this.operations.hydrateRepo(repo) + } + + getFolderWorkspaces(): FolderWorkspace[] { + return [...(this.state.folderWorkspaces ?? [])].sort( + (left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name) + ) + } + + getFolderWorkspace(id: string): FolderWorkspace | undefined { + return (this.state.folderWorkspaces ?? []).find((workspace) => workspace.id === id) + } + + createFolderWorkspace(input: { + projectGroupId: string + name?: string + folderPath?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] + connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }): FolderWorkspace { + const group = (this.state.projectGroups ?? []).find( + (entry) => entry.id === input.projectGroupId + ) + const folderPath = + typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 + ? input.folderPath + : group?.parentPath + if (!group || !folderPath) { + throw new Error('Folder-backed project group not found.') + } + const now = Date.now() + const linkedTask = normalizeWorkspaceLinkedItem(input.linkedTask) + const sourceContext = normalizeStoredTaskSourceContext(input.linkedTaskSourceContext) + const workspace: FolderWorkspace = { + id: randomUUID(), + projectGroupId: group.id, + name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`), + folderPath, + connectionId: input.connectionId ?? group.connectionId ?? null, + ...(input.creatorProvenance ? { creatorProvenance: input.creatorProvenance } : {}), + linkedTask, + linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) + ? sourceContext + : null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: now, + ...(input.createdWithAgent ? { createdWithAgent: input.createdWithAgent } : {}), + ...(input.pendingFirstAgentMessageRename === true && input.createdWithAgent + ? { pendingFirstAgentMessageRename: true } + : {}), + lastActivityAt: 0, + createdAt: now, + updatedAt: now + } + this.state.folderWorkspaces = [workspace, ...(this.state.folderWorkspaces ?? [])] + this.scheduleSave() + return workspace + } + + updateFolderWorkspace( + id: string, + updates: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'linkedTaskSourceContext' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + | 'diffComments' + > + > + ): FolderWorkspace | null { + const workspace = this.getFolderWorkspace(id) + if (!workspace) { + return null + } + if (updates.name !== undefined) { + workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name) + } + if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { + workspace.folderPath = updates.folderPath + } + if (updates.linkedTask !== undefined) { + workspace.linkedTask = normalizeWorkspaceLinkedItem(updates.linkedTask) + if ( + workspace.linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch( + workspace.linkedTask, + workspace.linkedTaskSourceContext + ) + ) { + workspace.linkedTaskSourceContext = null + } + } + if (updates.linkedTaskSourceContext !== undefined) { + const linkedTaskSourceContext = normalizeStoredTaskSourceContext( + updates.linkedTaskSourceContext + ) + workspace.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + workspace.linkedTask, + linkedTaskSourceContext + ) + ? linkedTaskSourceContext + : null + } + if (updates.comment !== undefined) { + workspace.comment = updates.comment + } + if (updates.isArchived !== undefined) { + workspace.isArchived = updates.isArchived + } + if (updates.isUnread !== undefined) { + workspace.isUnread = updates.isUnread + } + if (updates.isPinned !== undefined) { + workspace.isPinned = updates.isPinned + } + if (updates.sortOrder !== undefined && Number.isFinite(updates.sortOrder)) { + workspace.sortOrder = updates.sortOrder + } + if (updates.manualOrder !== undefined) { + if (Number.isFinite(updates.manualOrder)) { + workspace.manualOrder = updates.manualOrder + } else { + delete workspace.manualOrder + } + } + if (updates.workspaceStatus !== undefined) { + workspace.workspaceStatus = updates.workspaceStatus + } + if (updates.createdWithAgent !== undefined) { + workspace.createdWithAgent = updates.createdWithAgent + } + if (updates.pendingFirstAgentMessageRename !== undefined) { + workspace.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename + } + if (updates.firstAgentMessageRenameError !== undefined) { + workspace.firstAgentMessageRenameError = updates.firstAgentMessageRenameError + } + if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) { + workspace.lastActivityAt = updates.lastActivityAt + } + if (updates.diffComments !== undefined) { + workspace.diffComments = updates.diffComments + } + workspace.updatedAt = Date.now() + this.scheduleSave() + return workspace + } + + removeFolderWorkspace(id: string): boolean { + const before = this.state.folderWorkspaces?.length ?? 0 + this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( + (workspace) => workspace.id !== id + ) + if ((this.state.folderWorkspaces?.length ?? 0) === before) { + return false + } + this.state.workspaceSession = removeWorkspaceSessionOwner( + this.state.workspaceSession, + folderWorkspaceKey(id) + )! + this.removeWorkspaceLineageForFolderParent(id) + this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id)) + this.scheduleSave() + return true + } + + moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { + const repo = this.state.repos.find((entry) => entry.id === repoId) + if (!repo) { + return null + } + const normalizedGroupId = + groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId) + ? groupId + : null + const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId) + repo.projectGroupId = normalizedGroupId + repo.projectGroupOrder = + typeof order === 'number' && Number.isFinite(order) + ? order + : getNextProjectGroupOrder(siblingRepos, normalizedGroupId) + this.scheduleSave() + return this.hydrateRepo(repo) + } +} diff --git a/src/main/persistence/restoring-sessions/pane-alias-normalization.ts b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts new file mode 100644 index 00000000000..23cfaaa50c7 --- /dev/null +++ b/src/main/persistence/restoring-sessions/pane-alias-normalization.ts @@ -0,0 +1,198 @@ +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +import { + isTerminalLeafId, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../../shared/stable-pane-id' +import { agentHookServer } from '../../agent-hooks/server' + +export function legacyMigrationUnsupportedRowsToAliasEntries( + entries: MigrationUnsupportedPtyEntry[] +): LegacyPaneKeyAliasEntry[] { + const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter( + (entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey) + ) + const entriesByTabId = new Map() + 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 rows lack the old numeric key; only synthesize single-pane aliases when the row is unambiguous. + for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) { + aliasEntries.push({ + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.paneKey, + updatedAt: entry.updatedAt + }) + } + } + return aliasEntries +} + +// Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive. +export const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 + +// Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded. +export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 + +export function normalizeClaudeLivePtySessionIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + // Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy. + const ids: string[] = [] + for (let index = value.length - 1; index >= 0; index -= 1) { + const entry = value[index] + if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) { + continue + } + if (!ids.includes(entry)) { + ids.push(entry) + } + if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) { + break + } + } + return ids.toReversed() +} + +export 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 + 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) + ) + }) +} + +export 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 + 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 relocatedSource = parsePaneKey(candidate.legacyPaneKey) + const stable = parsePaneKey(candidate.stablePaneKey) + return Boolean(stable && ((legacy && legacy.tabId === stable.tabId) || relocatedSource)) + }) +} + +export function registerPersistedPaneKeyAlias(entry: LegacyPaneKeyAliasEntry): void { + if (parseLegacyNumericPaneKey(entry.legacyPaneKey)) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + return + } + // Why: detached agents keep their UUID pane key across restarts; restore the physical-to-owner mapping before hook replay. + agentHookServer.transferPaneAuthority( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { authorityVerified: false } + ) +} + +export function mergeLegacyPaneKeyAliasEntries( + entries: LegacyPaneKeyAliasEntry[] +): LegacyPaneKeyAliasEntry[] { + const byLegacyPaneKey = new Map() + 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()] +} + +export function legacyPaneKeyAliasEntriesEqual( + left: LegacyPaneKeyAliasEntry[], + right: LegacyPaneKeyAliasEntry[] +): boolean { + if (left.length !== right.length) { + return false + } + const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry])) + // Why: field-wise, not JSON.stringify — persisted key order differs from freshly built entries and would fake a dirty state. + return left.every((entry) => { + const other = rightByLegacyPaneKey.get(entry.legacyPaneKey) + return ( + other !== undefined && + entry.ptyId === other.ptyId && + entry.stablePaneKey === other.stablePaneKey && + entry.updatedAt === other.updatedAt + ) + }) +} + +export 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 !== undefined && + entry.worktreeId === other.worktreeId && + entry.tabId === other.tabId && + entry.leafId === other.leafId && + entry.paneKey === other.paneKey && + entry.reason === other.reason && + entry.source === other.source && + entry.updatedAt === other.updatedAt + ) + }) +} diff --git a/src/main/persistence/restoring-sessions/pane-identity-migration.ts b/src/main/persistence/restoring-sessions/pane-identity-migration.ts new file mode 100644 index 00000000000..e5a6fd1f1ea --- /dev/null +++ b/src/main/persistence/restoring-sessions/pane-identity-migration.ts @@ -0,0 +1,116 @@ +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' +import { agentHookServer } from '../../agent-hooks/server' +import { collectLayoutLeafIdsInOrder, firstLayoutLeafId } from './terminal-layout-normalization' + +export 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 +} + +export type PaneIdentityMigrationEntries = { + migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] +} + +export function collectMigrationUnsupportedPtyEntries(args: { + session: WorkspaceSessionState + tabId: string + inputLayout: TerminalLayoutSnapshot + normalizedLayout: TerminalLayoutSnapshot + leafIdByInputLeafId: Map +}): 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() + 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: PaneManager ids are 1-based; a zero-based alias in split layouts makes tab:1 ambiguous and misroutes panes. + 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) + let paneKey: string | undefined + if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) { + try { + paneKey = makePaneKey(args.tabId, fallbackLeafId) + } catch { + // Why: a persisted tabId can be malformed; skip the alias instead of aborting the whole load-time normalization. + } + } + if (paneKey) { + 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, not persisted as restart-required rows. + return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } +} diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts new file mode 100644 index 00000000000..ee06ada964b --- /dev/null +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -0,0 +1,107 @@ +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +export function createMinimalPersistedTerminalTab(args: { + worktreeId: string + tabId: string + ptyId: string + existingTabCount: number + startupCwd?: string +}): 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(), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + pendingActivationSpawn: true + } +} + +export function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSessionState { + return structuredClone(session) +} + +// Owner-keyed deletes only; pane-key-scanned collections live in deleteScannedSessionFieldsForOwners so a batch prune scans each once. +export function deleteOwnerKeyedSessionFields( + next: WorkspaceSessionState, + ownerKey: string, + removedTabIds: Set, + options: { advanceTerminalTopologyRevision?: boolean } = {} +): void { + const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? [] + if (next.tabsByWorktree) { + delete next.tabsByWorktree[ownerKey] + } + for (const tab of removedTerminalTabs) { + removedTabIds.add(tab.id) + delete next.terminalLayoutsByTabId[tab.id] + if (next.activeTabId === tab.id) { + next.activeTabId = null + } + } + if (options.advanceTerminalTopologyRevision) { + const repoId = getRepoIdFromWorktreeId(ownerKey) + const previousTopologyRevision = next.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + next.terminalTopologyRevisionByRepoId = { + ...next.terminalTopologyRevisionByRepoId, + [repoId]: previousTopologyRevision + 1 + } + } + if (next.openFilesByWorktree) { + delete next.openFilesByWorktree[ownerKey] + } + if (next.activeFileIdByWorktree) { + delete next.activeFileIdByWorktree[ownerKey] + } + const browserWorkspaces = next.browserTabsByWorktree?.[ownerKey] ?? [] + if (next.browserTabsByWorktree) { + delete next.browserTabsByWorktree[ownerKey] + } + if (next.browserPagesByWorkspace) { + for (const workspace of browserWorkspaces) { + delete next.browserPagesByWorkspace[workspace.id] + } + } + if (next.activeBrowserTabIdByWorktree) { + delete next.activeBrowserTabIdByWorktree[ownerKey] + } + if (next.activeTabTypeByWorktree) { + delete next.activeTabTypeByWorktree[ownerKey] + } + if (next.activeTabIdByWorktree) { + delete next.activeTabIdByWorktree[ownerKey] + } + if (next.unifiedTabs) { + delete next.unifiedTabs[ownerKey] + } + if (next.tabGroups) { + delete next.tabGroups[ownerKey] + } + if (next.tabGroupLayouts) { + delete next.tabGroupLayouts[ownerKey] + } + if (next.activeGroupIdByWorktree) { + delete next.activeGroupIdByWorktree[ownerKey] + } + if (next.lastVisitedAtByWorktreeId) { + delete next.lastVisitedAtByWorktreeId[ownerKey] + } + if (next.defaultTerminalTabsAppliedByWorktreeId) { + delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey] + } + if (next.activeWorkspaceKey === ownerKey) { + next.activeWorkspaceKey = null + } + if (next.activeWorktreeId === ownerKey) { + next.activeWorktreeId = null + } +} diff --git a/src/main/persistence/restoring-sessions/session-owner-removal.ts b/src/main/persistence/restoring-sessions/session-owner-removal.ts new file mode 100644 index 00000000000..80a149eda6a --- /dev/null +++ b/src/main/persistence/restoring-sessions/session-owner-removal.ts @@ -0,0 +1,98 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { cloneWorkspaceSessionState, deleteOwnerKeyedSessionFields } from './session-owner-fields' + +// Scans the pane-key-keyed maps and the shutdown list once, removing every entry +// owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose tab +// was removed). Kept separate from the O(1) deletes so a batch prune scans each +// collection a single time regardless of how many owners are being removed. +export function deleteScannedSessionFieldsForOwners( + next: WorkspaceSessionState, + removedTabIds: ReadonlySet, + isRemovedOwner: (worktreeId: string) => boolean +): void { + if (next.terminalPtyIncarnationsByPaneKey) { + next.terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator < 1 || !removedTabIds.has(paneKey.slice(0, separator)) + }) + ) + } + if (next.terminalSurfaceTombstonesByPaneKey) { + next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( + Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( + ([, tombstone]) => !isRemovedOwner(tombstone.worktreeId) + ) + ) + } + if (next.sleepingAgentSessionsByPaneKey) { + for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) { + if (isRemovedOwner(record.worktreeId)) { + delete next.sleepingAgentSessionsByPaneKey[paneKey] + } + } + } + next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( + (worktreeId) => !isRemovedOwner(worktreeId) + ) +} + +// Remote (ssh:/runtime:) workspace state can exist in both the renderer's local blob and main's host +// partition, because the renderer falls back to 'local' whenever worktree ownership is unresolved. +export function workspaceSessionPartitionIdsForHost( + hostId: string | null | undefined +): ExecutionHostId[] { + const parsed = parseExecutionHostId(hostId) + return parsed && parsed.id !== LOCAL_EXECUTION_HOST_ID + ? [LOCAL_EXECUTION_HOST_ID, parsed.id] + : [LOCAL_EXECUTION_HOST_ID] +} + +/** The partition the host actually owns; the others are only spill surfaces for it. */ +export function workspaceSessionOwnerPartitionForHost( + hostId: string | null | undefined +): ExecutionHostId { + return parseExecutionHostId(hostId)?.id ?? LOCAL_EXECUTION_HOST_ID +} + +export function removeWorkspaceSessionOwner( + session: WorkspaceSessionState | undefined, + ownerKey: string, + options: { advanceTerminalTopologyRevision?: boolean } = {} +): WorkspaceSessionState | undefined { + if (!session) { + return session + } + const next = cloneWorkspaceSessionState(session) + const removedTabIds = new Set() + deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds, options) + deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => worktreeId === ownerKey) + return next +} + +// Batch variant of removeWorkspaceSessionOwner: prunes every owner in `ownerKeys` +// with a single structuredClone and a single scan of each collection, instead of +// one clone+scan per owner. Project removal can touch many worktrees across many +// host partitions, so the per-owner clones added up to O(worktrees × hosts). +export function removeWorkspaceSessionOwners( + session: WorkspaceSessionState | undefined, + ownerKeys: ReadonlySet +): WorkspaceSessionState | undefined { + if (!session || ownerKeys.size === 0) { + return session + } + const next = cloneWorkspaceSessionState(session) + const removedTabIds = new Set() + for (const ownerKey of ownerKeys) { + deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds) + } + deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => + ownerKeys.has(worktreeId) + ) + return next +} diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts new file mode 100644 index 00000000000..9ee36737da1 --- /dev/null +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts @@ -0,0 +1,260 @@ +import { randomUUID } from 'node:crypto' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../shared/terminal-tab-types' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' + +export type LayoutLeafNormalization = { + snapshot: TerminalLayoutSnapshot + changed: boolean + leafIdByInputLeafId: Map +} + +export function collectLayoutLeafCounts( + node: TerminalPaneLayoutNode, + counts = new Map() +): Map { + 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 +} + +export function collectLayoutLeafIdsInOrder( + node: TerminalPaneLayoutNode | null | undefined +): string[] { + if (!node) { + return [] + } + if (node.type === 'leaf') { + return [node.leafId] + } + return [...collectLayoutLeafIdsInOrder(node.first), ...collectLayoutLeafIdsInOrder(node.second)] +} + +export function firstLayoutLeafId(node: TerminalPaneLayoutNode | null): string | null { + if (!node) { + return null + } + return node.type === 'leaf' ? node.leafId : firstLayoutLeafId(node.first) +} + +export 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) +} + +export 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) + } +} + +export function cloneLayoutWithLeafIds( + node: TerminalPaneLayoutNode, + leafIdByInputLeafId: Map, + duplicatedInputLeafIds: Set +): 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) + } +} + +export function remapLeafRecordForPersistence( + source: Record | undefined, + leafIdByInputLeafId: Map, + duplicatedInputLeafIds: Set +): Record | undefined { + if (!source) { + return undefined + } + const next: Record = {} + 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 +} + +export function leafRecordEquivalent( + left: Record | undefined, + right: Record | 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) +} + +export function preserveMissingLeafRecordEntries( + priorRecord: Record | undefined, + incomingRecord: Record | undefined, + liveLeafIds: Set +): Record | 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 +} + +export 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 the UUID root was sync-flushed. + 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() + 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 scrollbackRefsByLeafId = remapLeafRecordForPersistence( + inputSnapshot.scrollbackRefsByLeafId, + leafIdByInputLeafId, + duplicatedInputLeafIds + ) + const titlesByLeafId = remapLeafRecordForPersistence( + inputSnapshot.titlesByLeafId, + leafIdByInputLeafId, + duplicatedInputLeafIds + ) + const recordsChanged = + !leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) || + !leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) || + !leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) || + !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, + scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, + titlesByLeafId: _oldTitlesByLeafId, + ...snapshotWithoutLeafRecords + } = inputSnapshot + return { + snapshot: { + ...snapshotWithoutLeafRecords, + root, + activeLeafId, + expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + }, + changed: true, + leafIdByInputLeafId + } +} diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts new file mode 100644 index 00000000000..87e226086a1 --- /dev/null +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -0,0 +1,221 @@ +import type { LegacyPaneKeyAliasEntry, PersistedState } from '../../../shared/persisted-state-types' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import { collectMigrationUnsupportedPtyEntries } from './pane-identity-migration' +import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' +import { + legacyMigrationUnsupportedRowsToAliasEntries, + legacyPaneKeyAliasEntriesEqual, + mergeLegacyPaneKeyAliasEntries, + migrationUnsupportedEntriesEqual, + normalizeLegacyPaneKeyAliasEntries +} from './pane-alias-normalization' + +export function normalizeWorkspaceSessionPaneIdentities( + session: WorkspaceSessionState, + priorLayoutsByTabId: Record = {} +): { + session: WorkspaceSessionState + changed: boolean + leafIdByInputLeafIdByTabId: Map> + leafIdByPtyIdByTabId: Map> + migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] +} { + let changed = false + const leafIdByInputLeafIdByTabId = new Map>() + const leafIdByPtyIdByTabId = new Map>() + const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] + const terminalLayoutsByTabId: Record = {} + 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 + }) + // Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push(). + for (const entry of migrationEntries.migrationUnsupportedEntries) { + migrationUnsupportedEntries.push(entry) + } + for (const entry of migrationEntries.legacyPaneKeyAliasEntries) { + legacyPaneKeyAliasEntries.push(entry) + } + const leafIdByPtyId = new Map() + const duplicatePtyIds = new Set() + 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 + } +} + +export function remapSshRemotePtyLeaseLeafIds( + leases: SshRemotePtyLease[], + leafIdByInputLeafIdByTabId: Map>, + leafIdByPtyIdByTabId: Map> +): { 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; don't re-persist them as durable pane identity. + delete next.leafId + return next + }) + return { leases: nextLeases, changed } +} + +export 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 + } +} + +export function remapAcknowledgedAgentPaneKeys( + acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], + leafIdByInputLeafIdByTabId: Map> +): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { + if (!acknowledgements || Object.keys(acknowledgements).length === 0) { + return { acknowledgements, changed: false } + } + + let changed = false + const next: NonNullable = {} + 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: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread. + setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt) + changed = true + } catch { + setAcknowledgement(paneKey, acknowledgedAt) + } + } + + return { acknowledgements: next, changed } +} diff --git a/src/main/persistence/scheduling-automations/automation-context-migration.ts b/src/main/persistence/scheduling-automations/automation-context-migration.ts new file mode 100644 index 00000000000..b5c2fd6845d --- /dev/null +++ b/src/main/persistence/scheduling-automations/automation-context-migration.ts @@ -0,0 +1,232 @@ +import type { + Automation, + AutomationPrecheckResult, + AutomationRun, + AutomationRunOutputSnapshot, + AutomationSchedulerOwner +} from '../../../shared/automations-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectHostSetup } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' +import { getAutomationLegacyRepoId } from '../../../shared/automation-run-identity' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { + buildTaskSourceContextFromRepo, + buildWorkspaceRunContext +} from '../../../shared/task-source-context' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +export function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +export function normalizeAutomationRunTerminalPaneKey( + value: string | null | undefined +): string | null { + const trimmed = typeof value === 'string' ? value.trim() : '' + return trimmed && parsePaneKey(trimmed) ? trimmed : null +} + +export function normalizeAutomationRunTerminalPtyId( + value: string | null | undefined +): string | null { + const trimmed = typeof value === 'string' ? value.trim() : '' + return trimmed || null +} + +export 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 + } +} + +export function normalizeAutomationPrecheckResult( + value: AutomationPrecheckResult | null | undefined +): AutomationPrecheckResult | null { + if (!value || typeof value.command !== 'string' || !value.command.trim()) { + return null + } + const startedAt = + typeof value.startedAt === 'number' && Number.isFinite(value.startedAt) + ? value.startedAt + : Date.now() + const completedAt = + typeof value.completedAt === 'number' && Number.isFinite(value.completedAt) + ? value.completedAt + : startedAt + return { + command: value.command.trim(), + exitCode: + typeof value.exitCode === 'number' && Number.isFinite(value.exitCode) ? value.exitCode : null, + timedOut: value.timedOut === true, + durationMs: + typeof value.durationMs === 'number' && Number.isFinite(value.durationMs) + ? Math.max(0, value.durationMs) + : Math.max(0, completedAt - startedAt), + stdout: typeof value.stdout === 'string' ? value.stdout : '', + stderr: typeof value.stderr === 'string' ? value.stderr : '', + stdoutTruncated: value.stdoutTruncated === true, + stderrTruncated: value.stderrTruncated === true, + error: typeof value.error === 'string' && value.error.trim() ? value.error : null, + startedAt, + completedAt + } +} + +export function normalizeAutomationSessionReuse(automation: Automation): Automation { + const setupDecision = normalizeAutomationSetupDecisionForWorkspaceMode( + automation.workspaceMode, + automation.setupDecision + ) + return { + ...automation, + precheck: normalizeAutomationPrecheck(automation.precheck), + setupDecision, + reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true + } +} + +export function normalizeAutomationSetupDecisionForWorkspaceMode( + workspaceMode: Automation['workspaceMode'], + setupDecision: unknown +): Automation['setupDecision'] { + return workspaceMode === 'new_per_run' && (setupDecision === 'run' || setupDecision === 'skip') + ? setupDecision + : undefined +} + +export function getAutomationContextsForRepo( + repo: Repo | undefined, + projectHostSetups: readonly ProjectHostSetup[] +): Pick { + if (!repo) { + return { + runContext: null, + sourceContext: null + } + } + const projection = projectHostSetupProjectionFromRepos([repo]) + const projectedProject = projection.projects[0] + const projectedSetup = projection.setups[0] + const setup = + projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup + const runContext = setup + ? buildWorkspaceRunContext({ + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: repo.id, + path: setup.path + }) + : null + const providerIdentity = projectedProject?.providerIdentity + const sourceContext = providerIdentity + ? buildTaskSourceContextFromRepo({ + provider: providerIdentity.provider, + projectId: providerIdentity.provider === 'github' ? (setup?.projectId ?? repo.id) : repo.id, + repo, + projectHostSetupId: setup?.id, + providerIdentity + }) + : null + return { + runContext, + sourceContext + } +} + +export function getAutomationSchedulerOwner(repo: Repo | undefined): AutomationSchedulerOwner { + if (!repo) { + return 'local_host_service' + } + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (host?.kind === 'ssh') { + return 'ssh_bridge' + } + if (host?.kind === 'runtime') { + return 'remote_host_service' + } + return 'local_host_service' +} + +export function backfillLegacyAutomationContexts( + state: Pick +): { + state: Pick + changed: boolean +} { + let changed = false + const contextsByAutomationId = new Map>() + const reposById = new Map((state.repos ?? []).map((repo) => [repo.id, repo])) + const automations = (state.automations ?? []).map((automation) => { + const contexts = getAutomationContextsForRepo( + reposById.get(getAutomationLegacyRepoId(automation)), + state.projectHostSetups ?? [] + ) + const next: Automation = { ...automation } + if (!Object.hasOwn(next, 'runContext')) { + // Why: pre-host-context automations only stored a repo id; backfill the run target once so dispatch/precheck stop inferring it. + next.runContext = contexts.runContext + changed = true + } + if (!Object.hasOwn(next, 'sourceContext')) { + next.sourceContext = contexts.sourceContext + changed = true + } + contextsByAutomationId.set(next.id, { + runContext: next.runContext ?? null, + sourceContext: next.sourceContext ?? null + }) + return next + }) + const automationRuns = (state.automationRuns ?? []).map((run) => { + const automationContexts = contextsByAutomationId.get(run.automationId) + const next: AutomationRun = { ...run } + if (!Object.hasOwn(next, 'runContext')) { + next.runContext = automationContexts?.runContext ?? null + changed = true + } + if (!Object.hasOwn(next, 'sourceContext')) { + next.sourceContext = automationContexts?.sourceContext ?? null + changed = true + } + if (!Object.hasOwn(next, 'terminalPaneKey')) { + next.terminalPaneKey = null + changed = true + } + if (!Object.hasOwn(next, 'terminalPtyId')) { + next.terminalPtyId = null + changed = true + } + return next + }) + if (!changed) { + return { state, changed: false } + } + return { + state: { + ...state, + automations, + automationRuns + }, + changed: true + } +} diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts new file mode 100644 index 00000000000..f8ac0abe0a5 --- /dev/null +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto' +import type { + Automation, + AutomationCreateInput, + AutomationUpdateInput +} from '../../../shared/automations-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' +import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { + getAutomationContextsForRepo, + getAutomationSchedulerOwner, + normalizeAutomationSessionReuse, + normalizeAutomationSetupDecisionForWorkspaceMode +} from './automation-context-migration' + +export type AutomationDefinitionOperations = { + state: StoreOwnedPersistedState + flush: () => void + recordCreated: () => void +} + +export function listAutomations(state: PersistedState): Automation[] { + return (state.automations ?? []) + .map((automation) => normalizeAutomationSessionReuse(automation)) + .sort((left, right) => left.name.localeCompare(right.name)) +} + +export function createAutomation( + operations: AutomationDefinitionOperations, + input: AutomationCreateInput +): Automation { + const repo = operations.state.repos.find((entry) => entry.id === input.projectId) + const now = Date.now() + const executionTargetType = repo?.connectionId ? 'ssh' : 'local' + const schedulerOwner = getAutomationSchedulerOwner(repo) + const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? []) + const automation: Automation = { + id: randomUUID(), + name: input.name.trim() || 'Untitled automation', + prompt: input.prompt, + precheck: normalizeAutomationPrecheck(input.precheck), + agentId: input.agentId, + runContext: input.runContext ?? contexts.runContext, + sourceContext: input.sourceContext ?? contexts.sourceContext, + projectId: input.projectId, + executionTargetType, + executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', + schedulerOwner, + workspaceMode: input.workspaceMode, + workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, + baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, + setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode( + input.workspaceMode, + input.setupDecision + ), + reuseSession: input.workspaceMode === 'existing' ? (input.reuseSession ?? false) : false, + 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 + } + operations.state.automations = [...(operations.state.automations ?? []), automation] + operations.recordCreated() + operations.flush() + return automation +} + +export function updateAutomation( + operations: AutomationDefinitionOperations, + id: string, + updates: AutomationUpdateInput +): Automation { + const index = (operations.state.automations ?? []).findIndex((entry) => entry.id === id) + if (index === -1) { + throw new Error('Automation not found.') + } + const current = operations.state.automations[index] + const repoId = updates.projectId ?? current.projectId + const repo = operations.state.repos.find((entry) => entry.id === repoId) + const executionTargetType = repo?.connectionId ? 'ssh' : 'local' + const schedulerOwner = getAutomationSchedulerOwner(repo) + const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? []) + 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, + precheck: Object.hasOwn(updates, 'precheck') + ? normalizeAutomationPrecheck(updates.precheck) + : normalizeAutomationPrecheck(current.precheck), + projectId: repoId, + runContext: Object.hasOwn(updates, 'runContext') + ? (updates.runContext ?? null) + : updates.projectId !== undefined + ? contexts.runContext + : (current.runContext ?? contexts.runContext), + sourceContext: Object.hasOwn(updates, 'sourceContext') + ? (updates.sourceContext ?? null) + : updates.projectId !== undefined + ? contexts.sourceContext + : (current.sourceContext ?? contexts.sourceContext), + executionTargetType, + executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', + schedulerOwner, + 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, + setupDecision: + workspaceMode === 'new_per_run' + ? Object.hasOwn(updates, 'setupDecision') + ? normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, updates.setupDecision) + : normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, current.setupDecision) + : undefined, + reuseSession: + workspaceMode === 'existing' + ? (updates.reuseSession ?? current.reuseSession ?? false) + : false, + rrule, + dtstart, + nextRunAt: scheduleChanged + ? nextAutomationOccurrenceAfter(rrule, dtstart, Date.now()) + : current.nextRunAt, + updatedAt: Date.now() + } + operations.state.automations[index] = updated + operations.flush() + return updated +} + +export function deleteAutomation(operations: AutomationDefinitionOperations, id: string): void { + operations.state.automations = (operations.state.automations ?? []).filter( + (entry) => entry.id !== id + ) + operations.state.automationRuns = (operations.state.automationRuns ?? []).filter( + (entry) => entry.automationId !== id + ) + operations.flush() +} diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts new file mode 100644 index 00000000000..f83117edf8b --- /dev/null +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -0,0 +1,167 @@ +import { randomUUID } from 'node:crypto' +import type { + Automation, + AutomationDispatchResult, + AutomationRun, + AutomationRunTrigger +} from '../../../shared/automations-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { + nextAutomationRunNumber, + pruneAutomationRuns +} from '../../../shared/automation-run-retention' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { + normalizeAutomationPrecheckResult, + normalizeAutomationRunOutputSnapshot, + normalizeAutomationRunTerminalPaneKey, + normalizeAutomationRunTerminalPtyId, + normalizeAutomationRunWorkspaceDisplayName +} from './automation-context-migration' + +export type AutomationRunOperations = { + state: StoreOwnedPersistedState + flush: () => void + recordManualRun: () => void + getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null +} + +export function listAutomationRuns(state: PersistedState, automationId?: string): AutomationRun[] { + const runs = state.automationRuns ?? [] + return [...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)] + .map((run) => ({ + ...run, + precheckResult: normalizeAutomationPrecheckResult(run.precheckResult) + })) + .sort((left, right) => right.createdAt - left.createdAt) +} + +export function createAutomationRun( + operations: AutomationRunOperations, + automation: Automation, + scheduledFor: number, + trigger: AutomationRunTrigger = 'scheduled' +): AutomationRun { + const existing = (operations.state.automationRuns ?? []).find( + (run) => run.automationId === automation.id && run.scheduledFor === scheduledFor + ) + if (existing) { + return existing + } + const now = Date.now() + // Why: retention prunes old runs, so the retained count isn't the ordinal — carry the number forward from the newest survivor. + const runNumber = nextAutomationRunNumber( + (operations.state.automationRuns ?? []).filter((run) => run.automationId === automation.id) + ) + const run: AutomationRun = { + id: randomUUID(), + automationId: automation.id, + runNumber, + runContext: automation.runContext ?? null, + sourceContext: automation.sourceContext ?? null, + title: `${automation.name} run ${runNumber}`, + scheduledFor, + status: 'pending', + trigger, + workspaceId: automation.workspaceId, + workspaceDisplayName: operations.getWorkspaceDisplayName(automation.workspaceId), + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null, + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: null, + dispatchedAt: null, + createdAt: now + } + operations.state.automationRuns = pruneAutomationRuns([ + ...(operations.state.automationRuns ?? []), + run + ]) + if (trigger === 'manual') { + operations.recordManualRun() + } + operations.flush() + return run +} + +export function updateAutomationRun( + operations: AutomationRunOperations, + result: AutomationDispatchResult +): AutomationRun { + const index = (operations.state.automationRuns ?? []).findIndex( + (entry) => entry.id === result.runId + ) + if (index === -1) { + throw new Error('Automation run not found.') + } + const now = Date.now() + const current = operations.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) ?? + operations.getWorkspaceDisplayName(workspaceId), + terminalSessionId: Object.hasOwn(result, 'terminalSessionId') + ? (result.terminalSessionId ?? null) + : current.terminalSessionId, + terminalPaneKey: Object.hasOwn(result, 'terminalPaneKey') + ? normalizeAutomationRunTerminalPaneKey(result.terminalPaneKey) + : normalizeAutomationRunTerminalPaneKey(current.terminalPaneKey), + terminalPtyId: Object.hasOwn(result, 'terminalPtyId') + ? normalizeAutomationRunTerminalPtyId(result.terminalPtyId) + : normalizeAutomationRunTerminalPtyId(current.terminalPtyId), + outputSnapshot: Object.hasOwn(result, 'outputSnapshot') + ? normalizeAutomationRunOutputSnapshot(result.outputSnapshot) + : normalizeAutomationRunOutputSnapshot(current.outputSnapshot), + precheckResult: Object.hasOwn(result, 'precheckResult') + ? normalizeAutomationPrecheckResult(result.precheckResult) + : normalizeAutomationPrecheckResult(current.precheckResult), + 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 + } + operations.state.automationRuns[index] = updated + const automation = operations.state.automations.find((entry) => entry.id === updated.automationId) + if (automation) { + automation.lastRunAt = now + automation.updatedAt = now + } + operations.flush() + return updated +} + +export function snapshotAutomationRunWorkspaceDisplayName( + operations: AutomationRunOperations, + workspaceId: string, + displayName: string +): number { + const normalizedDisplayName = normalizeAutomationRunWorkspaceDisplayName(displayName) + if (!normalizedDisplayName) { + return 0 + } + let updatedCount = 0 + operations.state.automationRuns = (operations.state.automationRuns ?? []).map((run) => { + if (run.workspaceId !== workspaceId || run.workspaceDisplayName === normalizedDisplayName) { + return run + } + updatedCount += 1 + return { ...run, workspaceDisplayName: normalizedDisplayName } + }) + if (updatedCount > 0) { + operations.flush() + } + return updatedCount +} diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts new file mode 100644 index 00000000000..0dbf1f89845 --- /dev/null +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -0,0 +1,31 @@ +import type { Automation } from '../../../shared/automations-types' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { + latestAutomationOccurrenceAtOrBefore, + nextAutomationOccurrenceAfter +} from '../../../shared/automation-schedules' + +export function advanceAutomationNextRun( + state: StoreOwnedPersistedState, + flush: () => void, + id: string, + now = Date.now() +): Automation { + const index = (state.automations ?? []).findIndex((entry) => entry.id === id) + if (index === -1) { + throw new Error('Automation not found.') + } + const current = state.automations[index] + const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now) + const updated = { ...current, nextRunAt, updatedAt: Date.now() } + state.automations[index] = updated + flush() + return updated +} + +export function getLatestAutomationOccurrence( + automation: Automation, + now = Date.now() +): number | null { + return latestAutomationOccurrenceAtOrBefore(automation.rrule, automation.dtstart, now) +} diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts new file mode 100644 index 00000000000..95a5f2f1627 --- /dev/null +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -0,0 +1,123 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { + createProjectGroup, + getProjectGroupSubtreeIds, + normalizeProjectGroupName +} from '../../../shared/project-groups' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal' + +export type ProjectGroupMutationOperations = { + state: StoreOwnedPersistedState + scheduleSave: () => void + removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void + pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void +} + +export class ProjectGroupPersistenceOperations { + constructor(private readonly operations: ProjectGroupMutationOperations) {} + + private get state(): PersistedState { + return this.operations.state + } + + private scheduleSave(): void { + this.operations.scheduleSave() + } + + private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { + this.operations.removeWorkspaceLineageForFolderParent(folderWorkspaceId) + } + + private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { + this.operations.pruneMobileClientTabSelections(matchesWorktreeId) + } + + getProjectGroups(): ProjectGroup[] { + return [...(this.state.projectGroups ?? [])].sort( + (left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name) + ) + } + + createProjectGroup(input: { + name: string + parentPath?: string | null + connectionId?: string | null + parentGroupId?: string | null + createdFrom: ProjectGroup['createdFrom'] + }): ProjectGroup { + let maxOrder = -1 + // Why: persisted group lists can be large enough to exceed spread limits. + for (const existingGroup of this.state.projectGroups ?? []) { + maxOrder = Math.max(maxOrder, existingGroup.tabOrder) + } + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + 1 + }) + this.state.projectGroups = [...(this.state.projectGroups ?? []), group] + this.scheduleSave() + return group + } + + updateProjectGroup( + groupId: string, + updates: Partial> + ): ProjectGroup | null { + const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId) + if (!group) { + return null + } + if (updates.name !== undefined) { + group.name = normalizeProjectGroupName(updates.name, group.name) + } + if (updates.isCollapsed !== undefined) { + group.isCollapsed = updates.isCollapsed + } + if (updates.tabOrder !== undefined && Number.isFinite(updates.tabOrder)) { + group.tabOrder = updates.tabOrder + } + if (updates.color !== undefined) { + group.color = typeof updates.color === 'string' ? updates.color : null + } + group.updatedAt = Date.now() + this.scheduleSave() + return group + } + + deleteProjectGroup(groupId: string): boolean { + const before = this.state.projectGroups?.length ?? 0 + const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId) + this.state.projectGroups = (this.state.projectGroups ?? []).filter( + (group) => !deletedGroupIds.has(group.id) + ) + if ((this.state.projectGroups?.length ?? 0) === before) { + return false + } + // Why: groups are sidebar organization only, so deleting one ungroups its repos rather than deleting them. + this.state.repos = this.state.repos.map((repo) => + repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) + ? { ...repo, projectGroupId: null } + : repo + ) + const removedFolderWorkspaceKeys = new Set() + for (const workspace of this.state.folderWorkspaces ?? []) { + if (deletedGroupIds.has(workspace.projectGroupId)) { + removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id)) + this.state.workspaceSession = removeWorkspaceSessionOwner( + this.state.workspaceSession, + folderWorkspaceKey(workspace.id) + )! + this.removeWorkspaceLineageForFolderParent(workspace.id) + } + } + this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( + (workspace) => !deletedGroupIds.has(workspace.projectGroupId) + ) + this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId)) + this.scheduleSave() + return true + } +} diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.ts b/src/main/persistence/tracking-repos/project-host-compatibility.ts new file mode 100644 index 00000000000..88e80ecdb93 --- /dev/null +++ b/src/main/persistence/tracking-repos/project-host-compatibility.ts @@ -0,0 +1,83 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectHostSetup } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { carryProjectStateThroughIdentityChange } from '../../../shared/project-identity-succession' + +export function projectHostSetupCompatibilityStateEqual( + state: Pick, + nextState: Pick +): boolean { + return ( + JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) && + JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups) + ) +} + +export function isRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + currentRepoIds: ReadonlySet +): boolean { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) +} + +export function mergeProjectHostSetupCompatibilityState( + state: Pick, + repos: readonly Repo[] +): Pick { + const projection = projectHostSetupProjectionFromRepos(repos) + const succession = carryProjectStateThroughIdentityChange( + projection.projects, + state.projects ?? [] + ) + const currentRepoIds = new Set(repos.map((repo) => repo.id)) + const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) + const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + // Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts. + const independentSetups = (state.projectHostSetups ?? []) + .filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + // Why: follow the repo's project through a derived-id change so no ghost project row survives. + .map((setup) => { + const remappedProjectId = succession.remappedProjectIds.get(setup.projectId) + return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup + }) + const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) + const independentProjects = (state.projects ?? []) + .filter( + (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) + ) + .map((project) => ({ + ...project, + sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) + })) + return { + projects: [...succession.projects, ...independentProjects], + projectHostSetups: [...projection.setups, ...independentSetups] + } +} + +export function makeProjectHostSetupId( + projectId: string, + hostId: ExecutionHostId, + existingIds: ReadonlySet, + requestedId?: string +): string { + const baseId = requestedId?.trim() || `${projectId}::${hostId}` + if (!existingIds.has(baseId)) { + return baseId + } + let suffix = 2 + let candidate = `${baseId}::${suffix}` + while (existingIds.has(candidate)) { + suffix++ + candidate = `${baseId}::${suffix}` + } + return candidate +} diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts new file mode 100644 index 00000000000..ff992a32aa6 --- /dev/null +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -0,0 +1,231 @@ +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs +} from '../../../shared/project-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' +import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { makeProjectHostSetupId } from './project-host-compatibility' + +export type ProjectHostMutationOperations = { + state: StoreOwnedPersistedState + gitUsernameCache: Map + hydrateRepo: (repo: Repo) => Repo + updateRepoBackedProjectHostSetup: ( + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] + ) => { setup: ProjectHostSetup; repo: Repo } | null + updateIndependentProjectHostSetup: ( + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] + ) => ProjectHostSetup + removeProjectForHost: (id: string, hostId: ProjectHostSetup['hostId']) => void + scheduleSave: () => void +} + +export class ProjectHostPersistenceOperations { + constructor(private readonly operations: ProjectHostMutationOperations) {} + + private get state(): PersistedState { + return this.operations.state + } + + private get gitUsernameCache(): Map { + return this.operations.gitUsernameCache + } + + private hydrateRepo(repo: Repo): Repo { + return this.operations.hydrateRepo(repo) + } + + private updateRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] + ): { setup: ProjectHostSetup; repo: Repo } | null { + return this.operations.updateRepoBackedProjectHostSetup(setup, repo, updates) + } + + private updateIndependentProjectHostSetup( + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] + ): ProjectHostSetup { + return this.operations.updateIndependentProjectHostSetup(setup, updates) + } + + private removeProjectForHost(id: string, hostId: ProjectHostSetup['hostId']): void { + this.operations.removeProjectForHost(id, hostId) + } + + private scheduleSave(): void { + this.operations.scheduleSave() + } + + // ── Repos ────────────────────────────────────────────────────────── + + getRepos(): Repo[] { + return this.state.repos.map((repo) => this.hydrateRepo(repo)) + } + + getProjects(): Project[] { + return [...this.state.projects] + } + + updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { + const project = this.state.projects.find((entry) => entry.id === id) + if (!project) { + return null + } + if ('localWindowsRuntimePreference' in updates) { + if (updates.localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = normalizeProjectRuntimePreference( + updates.localWindowsRuntimePreference + ) + } + } + project.updatedAt = Date.now() + this.scheduleSave() + return { ...project } + } + + getProjectHostSetups(): ProjectHostSetup[] { + return [...this.state.projectHostSetups] + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + const project = this.state.projects.find((entry) => entry.id === args.projectId) + if (!project) { + return null + } + const hostId = normalizeExecutionHostId(args.hostId) + if (!hostId) { + throw new Error(`Invalid host ID: ${args.hostId}`) + } + const duplicateSetup = this.state.projectHostSetups.find( + (entry) => entry.projectId === project.id && entry.hostId === hostId + ) + if (duplicateSetup) { + throw new Error(`Project host setup already exists: ${duplicateSetup.id}`) + } + const now = Date.now() + const existingIds = new Set(this.state.projectHostSetups.map((entry) => entry.id)) + const setup: ProjectHostSetup = { + id: makeProjectHostSetupId(project.id, hostId, existingIds, args.setupId), + projectId: project.id, + hostId, + repoId: '', + path: args.path?.trim() ?? '', + displayName: args.displayName?.trim() || project.displayName, + ...(args.kind ? { kind: args.kind } : {}), + ...(args.worktreeBasePath?.trim() ? { worktreeBasePath: args.worktreeBasePath.trim() } : {}), + ...(args.gitUsername?.trim() ? { gitUsername: args.gitUsername.trim() } : {}), + setupState: args.setupState ?? 'not-set-up', + setupMethod: args.setupMethod ?? 'provisioned', + createdAt: now, + updatedAt: now + } + // Why: persist independently so future repo projection sync doesn't erase this non-repo-backed setup. + this.state.projectHostSetups.push(setup) + this.scheduleSave() + return { project, setup } + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + if (!setup) { + return null + } + const project = this.state.projects.find((entry) => entry.id === setup.projectId) + if (!project) { + return null + } + const repo = setup.repoId + ? this.state.repos.find((entry) => entry.id === setup.repoId) + : undefined + if (repo) { + const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates) + const updatedProject = updated + ? this.state.projects.find((entry) => entry.id === updated.setup.projectId) + : undefined + return updated && updatedProject + ? { project: updatedProject, setup: updated.setup, repo: updated.repo } + : null + } + const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates) + return { project, setup: updatedSetup } + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + if (!setup) { + return null + } + const project = this.state.projects.find((entry) => entry.id === setup.projectId) + if (!project) { + return null + } + // Why: the same repo id can exist on multiple execution hosts, so match this setup's own host + // row and never fall back to a sibling host's row — a stale repoId/hostId would delete that host's + // registration. With no exact match the setup is stale, and the path below drops just the setup. + const repo = setup.repoId + ? this.state.repos.find( + (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId + ) + : undefined + if (repo) { + this.removeProjectForHost(repo.id, setup.hostId) + return { project, setup, repo: this.hydrateRepo(repo) } + } + this.state.projectHostSetups = this.state.projectHostSetups.filter( + (entry) => entry.id !== setup.id + ) + this.scheduleSave() + return { project, setup } + } + + /** O(1) repo count; unlike `getRepos()` this skips per-repo hydration. */ + 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 + } + + /** + * Record a background-resolved git username; kept out of updateRepo's whitelist so the renderer can't write it directly. + * @returns true when the hydrated value changed. + */ + setResolvedRepoGitUsername(id: string, username: string): boolean { + const repo = this.state.repos.find((r) => r.id === id) + if (!repo) { + return false + } + const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '' + this.gitUsernameCache.set(repo.path, username) + if (previous === username) { + return false + } + if (username) { + // Why: persist so the next launch hydrates repos with the right branch prefix before enrichment re-runs. + repo.gitUsername = username + } else { + delete repo.gitUsername + } + this.scheduleSave() + return true + } +} diff --git a/src/main/persistence/tracking-repos/project-host-setup-update.ts b/src/main/persistence/tracking-repos/project-host-setup-update.ts new file mode 100644 index 00000000000..2a72ab055c8 --- /dev/null +++ b/src/main/persistence/tracking-repos/project-host-setup-update.ts @@ -0,0 +1,109 @@ +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import type { RepoUpdatePersistenceOperations } from './repo-update-operations' + +export type ProjectHostSetupUpdateOperations = { + state: StoreOwnedPersistedState + updateRepo: RepoUpdatePersistenceOperations['updateRepo'] + scheduleSave: () => void +} + +export class ProjectHostSetupPersistenceOperations { + constructor(private readonly operations: ProjectHostSetupUpdateOperations) {} + + private get state(): PersistedState { + return this.operations.state + } + + private updateRepo( + ...args: Parameters + ): ReturnType { + return this.operations.updateRepo(...args) + } + + private scheduleSave(): void { + this.operations.scheduleSave() + } + + updateRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] + ): { setup: ProjectHostSetup; repo: Repo } | null { + if (updates.path !== undefined && updates.path !== repo.path) { + throw new Error( + 'Repo-backed project host setup paths must be changed by re-importing the project.' + ) + } + if (updates.setupState !== undefined && updates.setupState !== 'ready') { + throw new Error('Repo-backed project host setups cannot be marked unavailable.') + } + const repoUpdates: Parameters[1] = {} + if (updates.displayName !== undefined) { + repoUpdates.displayName = updates.displayName + } + if (updates.worktreeBasePath !== undefined) { + repoUpdates.worktreeBasePath = updates.worktreeBasePath + } + if (updates.kind !== undefined) { + repoUpdates.kind = updates.kind + } + if (updates.setupMethod === 'provisioned') { + throw new Error('Repo-backed project host setups cannot be marked provisioned.') + } + if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') { + repoUpdates.projectHostSetupMethod = updates.setupMethod + } + const updatedRepo = + Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo + if (!updatedRepo) { + return null + } + return { + setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup, + repo: updatedRepo + } + } + + updateIndependentProjectHostSetup( + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] + ): ProjectHostSetup { + if (updates.displayName !== undefined) { + setup.displayName = updates.displayName.trim() || setup.displayName + } + if (updates.path !== undefined) { + setup.path = updates.path.trim() || setup.path + } + if (updates.worktreeBasePath !== undefined) { + const worktreeBasePath = updates.worktreeBasePath.trim() + if (worktreeBasePath) { + setup.worktreeBasePath = worktreeBasePath + } else { + delete setup.worktreeBasePath + } + } + if (updates.kind !== undefined) { + setup.kind = updates.kind + } + if (updates.gitUsername !== undefined) { + const gitUsername = updates.gitUsername.trim() + if (gitUsername) { + setup.gitUsername = gitUsername + } else { + delete setup.gitUsername + } + } + if (updates.setupState !== undefined) { + setup.setupState = updates.setupState + } + if (updates.setupMethod !== undefined) { + setup.setupMethod = updates.setupMethod + } + setup.updatedAt = Date.now() + this.scheduleSave() + return setup + } +} diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts new file mode 100644 index 00000000000..00f5b8fa1e9 --- /dev/null +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -0,0 +1,69 @@ +import type { Repo } from '../../../shared/repo-types' +import { getDefaultRepoHookSettings } from '../../../shared/constants' +import { isFolderRepo } from '../../../shared/repo-kind' +import { sanitizeRepoIcon } from '../../../shared/repo-icon' +import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' +import { + sanitizeForkSyncMode, + sanitizeGitRemoteIdentity, + sanitizeRepoProjectHostSetupMethod, + sanitizeRepoUpstream +} from './repo-sanitization' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../../../shared/worktree/visibility-sources' + +export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap): Repo { + const { + repoIcon: rawRepoIcon, + upstream: rawUpstream, + gitRemoteIdentity: rawGitRemoteIdentity, + sourceControlAi: rawSourceControlAi, + projectHostSetupMethod: rawProjectHostSetupMethod, + forkSyncMode: rawForkSyncMode, + customWorktreeVisibilitySources: rawCustomWorktreeVisibilitySources, + worktreeVisibilitySourcePreferences: rawWorktreeVisibilitySourcePreferences, + ...repoWithoutIcon + } = repo + const repoIcon = sanitizeRepoIcon(rawRepoIcon) + const upstream = sanitizeRepoUpstream(rawUpstream) + const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity) + const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi) + const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod) + const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode) + const customWorktreeVisibilitySources = normalizeCustomWorktreeVisibilitySources( + rawCustomWorktreeVisibilitySources + ) + const worktreeVisibilitySourcePreferences = normalizeWorktreeVisibilitySourcePreferences( + rawWorktreeVisibilitySourcePreferences + ) + // Why: never spawn git/gh username resolution in hydration — a stuck probe froze Windows startup for minutes (issue #7225); read only cache/persisted value. + const gitUsername = isFolderRepo(repo) + ? '' + : (gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '') + + return { + ...repoWithoutIcon, + ...(repoIcon !== undefined ? { repoIcon } : {}), + ...(upstream !== undefined ? { upstream } : {}), + ...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}), + ...(sourceControlAi !== undefined ? { sourceControlAi } : {}), + ...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}), + ...(forkSyncMode !== undefined ? { forkSyncMode } : {}), + ...(customWorktreeVisibilitySources !== undefined ? { customWorktreeVisibilitySources } : {}), + ...(worktreeVisibilitySourcePreferences !== undefined + ? { worktreeVisibilitySourcePreferences } + : {}), + kind: isFolderRepo(repo) ? 'folder' : 'git', + gitUsername, + hookSettings: { + ...getDefaultRepoHookSettings(), + ...repo.hookSettings, + scripts: { + ...getDefaultRepoHookSettings().scripts, + ...repo.hookSettings?.scripts + } + } + } +} diff --git a/src/main/persistence/tracking-repos/repo-order-operations.ts b/src/main/persistence/tracking-repos/repo-order-operations.ts new file mode 100644 index 00000000000..d578b20dcf7 --- /dev/null +++ b/src/main/persistence/tracking-repos/repo-order-operations.ts @@ -0,0 +1,93 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' + +export type RepoOrderMutationOperations = { + state: StoreOwnedPersistedState + syncProjectHostSetupCompatibilityState: () => void + scheduleSave: () => void +} + +export class RepoOrderPersistenceOperations { + constructor(private readonly operations: RepoOrderMutationOperations) {} + + private get state(): PersistedState { + return this.operations.state + } + + private syncProjectHostSetupCompatibilityState(): void { + this.operations.syncProjectHostSetupCompatibilityState() + } + + private scheduleSave(): void { + this.operations.scheduleSave() + } + + addRepo(repo: Repo): void { + this.state.repos.push(repo) + this.syncProjectHostSetupCompatibilityState() + this.scheduleSave() + } + + // Why: return false on a stale permutation (concurrent add/remove) so the caller resyncs instead of persisting an order that drops/duplicates ids. + reorderRepos(orderedIds: string[]): boolean { + const current = this.state.repos + if (orderedIds.length !== current.length) { + return false + } + const seen = new Set() + for (const id of orderedIds) { + if (typeof id !== 'string' || seen.has(id)) { + return false + } + seen.add(id) + } + const byId = new Map() + 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.syncProjectHostSetupCompatibilityState() + this.scheduleSave() + return true + } + + // Why: repo ids are unique only within an execution host; drags persist one permutation per host when local and SSH repos coexist. + reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { + const current = this.state.repos + const hostRepos = current.filter((repo) => getRepoExecutionHostId(repo) === hostId) + if (orderedIds.length !== hostRepos.length) { + return false + } + const byId = new Map(hostRepos.map((repo) => [repo.id, repo])) + if (byId.size !== hostRepos.length) { + return false + } + const seen = new Set() + const reorderedHostRepos: Repo[] = [] + for (const id of orderedIds) { + const repo = typeof id === 'string' && !seen.has(id) ? byId.get(id) : undefined + if (!repo) { + return false + } + seen.add(id) + reorderedHostRepos.push(repo) + } + let nextHostIndex = 0 + this.state.repos = current.map((repo) => + getRepoExecutionHostId(repo) === hostId ? reorderedHostRepos[nextHostIndex++] : repo + ) + this.syncProjectHostSetupCompatibilityState() + this.scheduleSave() + return true + } +} diff --git a/src/main/persistence/tracking-repos/repo-sanitization.ts b/src/main/persistence/tracking-repos/repo-sanitization.ts new file mode 100644 index 00000000000..da668280d26 --- /dev/null +++ b/src/main/persistence/tracking-repos/repo-sanitization.ts @@ -0,0 +1,162 @@ +import type { RepoProjectHostSetupMethod } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import type { GitRemoteIdentity } from '../../../shared/git-remote-identity' +import { normalizeRepoBadgeColor } from '../../../shared/repo-badge-color' +import { sanitizeRepoIcon } from '../../../shared/repo-icon' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../../../shared/worktree/visibility-sources' + +export function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { + if (value === undefined) { + return undefined + } + if (value === null) { + return null + } + if (!value || typeof value !== 'object') { + return undefined + } + const candidate = value as { owner?: unknown; repo?: unknown; host?: unknown } + const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : '' + const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : '' + if (!owner || !repo) { + return undefined + } + // Why: an `upstream` remote may live on a different server than `origin`, so + // dropping the host forced consumers to re-infer it from origin and could bind + // a GHES parent to a same-named github.com repo. Absent host stays absent so + // records written before this survive unchanged. + const host = typeof candidate.host === 'string' ? candidate.host.trim() : '' + return host ? { owner, repo, host } : { owner, repo } +} + +export function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined { + // Why: `null` is a resolved "no usable remote" marker; dropping it would make + // a settled repo indistinguishable from one whose identity probe is pending. + if (value === null) { + return null + } + if (!value || typeof value !== 'object') { + return undefined + } + const candidate = value as { + canonicalKey?: unknown + remoteName?: unknown + remoteUrl?: unknown + } + const canonicalKey = + typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : '' + const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : '' + const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : '' + return canonicalKey && remoteName && remoteUrl + ? { canonicalKey, remoteName, remoteUrl } + : undefined +} + +export function sanitizeRepoProjectHostSetupMethod( + value: unknown +): RepoProjectHostSetupMethod | undefined { + return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined +} + +export function sanitizeForkSyncMode(value: unknown): Repo['forkSyncMode'] | undefined { + return value === 'ask' || value === 'safe-auto' || value === 'off' ? value : undefined +} + +export function sanitizeRepoUpdatesForPersistence< + T extends Partial< + Pick< + Repo, + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'gitRemoteIdentity' + | 'worktreeBasePath' + | 'projectHostSetupMethod' + | 'forkSyncMode' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + > + > +>(updates: T): T { + const sanitized = { ...updates } + if ('badgeColor' in sanitized) { + const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) + if (!badgeColor) { + delete sanitized.badgeColor + } else { + sanitized.badgeColor = badgeColor + } + } + if ('repoIcon' in sanitized) { + const repoIcon = sanitizeRepoIcon(sanitized.repoIcon) + if (repoIcon === undefined) { + delete sanitized.repoIcon + } else { + sanitized.repoIcon = repoIcon + } + } + // Why: `null` is a valid "not a fork" / "no usable remote" marker; only drop malformed shapes. + if ('upstream' in sanitized) { + const upstream = sanitizeRepoUpstream(sanitized.upstream) + if (upstream === undefined) { + delete sanitized.upstream + } else { + sanitized.upstream = upstream + } + } + if ('gitRemoteIdentity' in sanitized) { + const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity) + if (gitRemoteIdentity === undefined) { + delete sanitized.gitRemoteIdentity + } else { + sanitized.gitRemoteIdentity = gitRemoteIdentity + } + } + if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { + if (typeof sanitized.worktreeBasePath === 'string') { + sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined + } else { + delete sanitized.worktreeBasePath + } + } + if ('projectHostSetupMethod' in sanitized) { + const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod) + if (setupMethod === undefined) { + delete sanitized.projectHostSetupMethod + } else { + sanitized.projectHostSetupMethod = setupMethod + } + } + if ('forkSyncMode' in sanitized) { + const forkSyncMode = sanitizeForkSyncMode(sanitized.forkSyncMode) + if (forkSyncMode === undefined) { + delete sanitized.forkSyncMode + } else { + sanitized.forkSyncMode = forkSyncMode + } + } + if ('customWorktreeVisibilitySources' in sanitized) { + const sources = normalizeCustomWorktreeVisibilitySources( + sanitized.customWorktreeVisibilitySources + ) + if (!sources) { + delete sanitized.customWorktreeVisibilitySources + } else { + sanitized.customWorktreeVisibilitySources = sources + } + } + if ('worktreeVisibilitySourcePreferences' in sanitized) { + const preferences = normalizeWorktreeVisibilitySourcePreferences( + sanitized.worktreeVisibilitySourcePreferences + ) + if (!preferences) { + delete sanitized.worktreeVisibilitySourcePreferences + } else { + sanitized.worktreeVisibilitySourcePreferences = preferences + } + } + return sanitized +} diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts new file mode 100644 index 00000000000..67161c9d5af --- /dev/null +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -0,0 +1,179 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility' +import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' + +export type RepoUpdateMutationOperations = { + state: StoreOwnedPersistedState + syncProjectHostSetupCompatibilityState: () => void + scheduleSave: () => void + hydrateRepo: (repo: Repo) => Repo +} + +export class RepoUpdatePersistenceOperations { + constructor(private readonly operations: RepoUpdateMutationOperations) {} + + private get state(): PersistedState { + return this.operations.state + } + + private syncProjectHostSetupCompatibilityState(): void { + this.operations.syncProjectHostSetupCompatibilityState() + } + + private scheduleSave(): void { + this.operations.scheduleSave() + } + + private hydrateRepo(repo: Repo): Repo { + return this.operations.hydrateRepo(repo) + } + + updateRepo( + id: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'gitRemoteIdentity' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'executionHostId' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'forkSyncMode' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + | 'projectHostSetupMethod' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + }, + hostId?: ExecutionHostId + ): Repo | null { + const repo = this.state.repos.find( + (candidate) => + candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) + ) + if (!repo) { + return null + } + const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates) + if ( + 'agentWorktreeVisibility' in sanitizedUpdates && + !('worktreeVisibilitySourcePreferences' in sanitizedUpdates) && + (sanitizedUpdates.agentWorktreeVisibility === 'hide' || + sanitizedUpdates.agentWorktreeVisibility === 'show') + ) { + sanitizedUpdates.worktreeVisibilitySourcePreferences = { + ...repo.worktreeVisibilitySourcePreferences, + builtIn: { + claude: sanitizedUpdates.agentWorktreeVisibility, + gsd: sanitizedUpdates.agentWorktreeVisibility + } + } + } + if ('projectGroupId' in sanitizedUpdates) { + const nextGroupId = sanitizedUpdates.projectGroupId + if ( + typeof nextGroupId !== 'string' || + nextGroupId.trim().length === 0 || + !this.state.projectGroups.some((group) => group.id === nextGroupId) + ) { + sanitizedUpdates.projectGroupId = null + } + } + if ( + 'projectGroupOrder' in sanitizedUpdates && + (typeof sanitizedUpdates.projectGroupOrder !== 'number' || + !Number.isFinite(sanitizedUpdates.projectGroupOrder)) + ) { + delete sanitizedUpdates.projectGroupOrder + } + const externalWorktreeVisibilityLegacy = + 'externalWorktreeVisibility' in sanitizedUpdates && + repo.externalWorktreeVisibilityLegacy === undefined + ? isLegacyRepoForExternalWorktreeVisibility(repo) + : undefined + // Why: selected repo fields use `undefined` as an explicit clear signal, so delete them before assigning the patch. + if ( + 'issueSourcePreference' in sanitizedUpdates && + sanitizedUpdates.issueSourcePreference === undefined + ) { + delete repo.issueSourcePreference + delete sanitizedUpdates.issueSourcePreference + } + if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { + delete repo.worktreeBasePath + delete sanitizedUpdates.worktreeBasePath + } + if ( + 'externalWorktreeVisibility' in sanitizedUpdates && + (sanitizedUpdates.externalWorktreeVisibility === undefined || + sanitizedUpdates.externalWorktreeVisibility === null) + ) { + delete repo.externalWorktreeVisibility + repo.externalWorktreeVisibilityLegacy = false + delete sanitizedUpdates.externalWorktreeVisibility + } + if ( + 'agentWorktreeVisibility' in sanitizedUpdates && + sanitizedUpdates.agentWorktreeVisibility === null + ) { + delete repo.agentWorktreeVisibility + delete sanitizedUpdates.agentWorktreeVisibility + } + if ( + 'externalWorktreeVisibility' in sanitizedUpdates && + repo.externalWorktreeVisibilityLegacy === undefined + ) { + // Why: old persisted repos have no marker; stamp it on first visibility change so later hide/show keeps legacy safety. + repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy + } + if ( + 'externalWorktreeDiscoverySuppressedAt' in sanitizedUpdates && + (sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === undefined || + sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === null) + ) { + delete repo.externalWorktreeDiscoverySuppressedAt + delete sanitizedUpdates.externalWorktreeDiscoverySuppressedAt + } + if ( + 'sourceControlAi' in sanitizedUpdates && + (sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null) + ) { + delete repo.sourceControlAi + delete sanitizedUpdates.sourceControlAi + } else if ('sourceControlAi' in sanitizedUpdates) { + const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides( + sanitizedUpdates.sourceControlAi + ) + if (normalizedSourceControlAi === undefined) { + delete sanitizedUpdates.sourceControlAi + } else { + sanitizedUpdates.sourceControlAi = normalizedSourceControlAi + } + } + Object.assign(repo, sanitizedUpdates) + this.syncProjectHostSetupCompatibilityState() + this.scheduleSave() + return this.hydrateRepo(repo) + } +} diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts new file mode 100644 index 00000000000..fa492890ba0 --- /dev/null +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -0,0 +1,104 @@ +import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' + +export function pruneWorktreeStateForRepo( + state: StoreOwnedPersistedState, + id: string, + hostId: ExecutionHostId | null, + pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void +): void { + const prefix = `${id}::` + // Why snapshot up front: the first loop deletes metas, so reading meta.hostId live later would misclassify an SSH worktree as local. + const hostMembership = new Map() + const belongsToHost = (key: string): boolean => { + if (!key.startsWith(prefix)) { + return false + } + if (hostId === null) { + return true + } + const cached = hostMembership.get(key) + if (cached !== undefined) { + return cached + } + // Why default to local: metas without hostId predate host stamping, so a host-scoped prune skips them rather than risk deleting another host's live meta. + const metaHostId = state.worktreeMeta[key]?.hostId ?? LOCAL_EXECUTION_HOST_ID + const result = metaHostId === hostId + hostMembership.set(key, result) + return result + } + // Why: session state (legacy blob + per-host partitions) references worktrees + // by the same `${repoId}::${path}` owner key; if it is not pruned here, a + // deleted project's worktrees stay in lastVisitedAtByWorktreeId / + // sleepingAgentSessionsByPaneKey and get re-materialized into worktreeMeta on + // the next launch, surfacing as an orphaned "unknown" workspace. + // worktreeMeta is host-classified via belongsToHost, but session partitions + // are keyed by host directly. A session owner key carries no host, and the + // same key can exist in multiple partitions (shared repo id/path across + // hosts). So for session cleanup we collect every prefix-matching owner key + // regardless of belongsToHost, and let the per-partition host gating below + // decide which partition to touch. (belongsToHost still governs + // worktreeMeta/lineage deletion. Collect before deleting worktreeMeta.) + const ownerKeysToPrune = new Set() + const collectPrefixedKeys = (keys: Iterable): void => { + for (const key of keys) { + if (key.startsWith(prefix)) { + ownerKeysToPrune.add(key) + } + } + } + collectPrefixedKeys(Object.keys(state.worktreeMeta)) + collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + } + + for (const key of Object.keys(state.worktreeMeta)) { + if (belongsToHost(key)) { + delete state.worktreeMeta[key] + } + } + // Why: owner keys are `${repoId}::${path}` and do not carry a host, so a + // host-scoped prune (hostId != null) must only touch that host's session: + // the legacy blob is the local host's session, and each + // workspaceSessionsByHostId partition is one non-local host. Pruning every + // partition here would wipe a surviving host's tabs, sleeping-agent state, + // and active-worktree pointer for a shared repo id/path. A full removal + // (hostId === null) still clears every host. + const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID + const pruneAllHostPartitions = hostId === null + if (pruneLegacyLocalSession) { + state.workspaceSession = removeWorkspaceSessionOwners(state.workspaceSession, ownerKeysToPrune)! + } + if (state.workspaceSessionsByHostId) { + for (const [partitionHostId, session] of Object.entries(state.workspaceSessionsByHostId)) { + if (!pruneAllHostPartitions && partitionHostId !== hostId) { + continue + } + const pruned = removeWorkspaceSessionOwners(session, ownerKeysToPrune) + if (pruned) { + state.workspaceSessionsByHostId[partitionHostId] = pruned + } + } + } + for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) { + if (belongsToHost(childId) || belongsToHost(lineage.parentWorktreeId)) { + delete state.worktreeLineageById[childId] + } + } + for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + const childScope = parseWorkspaceKey(childKey) + const parentScope = parseWorkspaceKey(lineage.parentWorkspaceKey) + if (childScope?.type === 'worktree' && belongsToHost(childScope.worktreeId)) { + delete state.workspaceLineageByChildKey[childKey as WorkspaceKey] + continue + } + if (parentScope?.type === 'worktree' && belongsToHost(parentScope.worktreeId)) { + delete state.workspaceLineageByChildKey[childKey as WorkspaceKey] + } + } + pruneMobileClientTabSelections(belongsToHost) +} diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts new file mode 100644 index 00000000000..e743d70c9a3 --- /dev/null +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -0,0 +1,205 @@ +import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' + +/** + * Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place; + * returns whether anything changed so the caller can gate its save. No-op when the ids match. + * See `Store.migrateWorktreeIdentity` for why the rename happens. + */ +export function migrateWorktreeIdentity( + state: StoreOwnedPersistedState, + oldWorktreeId: string, + newWorktreeId: string +): boolean { + if (oldWorktreeId === newWorktreeId) { + return false + } + const oldWorkspaceKey = worktreeWorkspaceKey(oldWorktreeId) + const newWorkspaceKey = worktreeWorkspaceKey(newWorktreeId) + const moveKey = ( + record: Record, + mapValue: (value: T) => T = (value) => value + ): boolean => { + if (!(oldWorktreeId in record)) { + return false + } + record[newWorktreeId] = mapValue(record[oldWorktreeId]) + delete record[oldWorktreeId] + return true + } + const withNewWorktreeId = (value: T): T => + value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value + const migrateSession = (session: WorkspaceSessionState | undefined): boolean => { + if (!session) { + return false + } + let sessionChanged = false + const moveSessionKey = ( + record: Record | undefined, + mapValue: (value: T) => T = (value) => value + ): boolean => { + if (!record) { + return false + } + let moved = false + const pairs: [string, string][] = [ + [oldWorktreeId, newWorktreeId], + [oldWorkspaceKey, newWorkspaceKey] + ] + for (const [oldKey, newKey] of pairs) { + if (!(oldKey in record)) { + continue + } + record[newKey] = mapValue(record[oldKey]) + delete record[oldKey] + moved = true + } + return moved + } + + sessionChanged = + moveSessionKey(session.tabsByWorktree, (tabs) => tabs.map(withNewWorktreeId)) || + sessionChanged + sessionChanged = + moveSessionKey(session.openFilesByWorktree, (files) => files.map(withNewWorktreeId)) || + sessionChanged + sessionChanged = moveSessionKey(session.activeFileIdByWorktree) || sessionChanged + sessionChanged = + moveSessionKey(session.browserTabsByWorktree, (workspaces) => + workspaces.map(withNewWorktreeId) + ) || sessionChanged + if (session.browserPagesByWorkspace) { + let pagesChanged = false + const nextPagesByWorkspace = { ...session.browserPagesByWorkspace } + for (const [workspaceId, pages] of Object.entries(nextPagesByWorkspace)) { + if (!pages.some((page) => page.worktreeId === oldWorktreeId)) { + continue + } + nextPagesByWorkspace[workspaceId] = pages.map(withNewWorktreeId) + pagesChanged = true + } + if (pagesChanged) { + session.browserPagesByWorkspace = nextPagesByWorkspace + sessionChanged = true + } + } + sessionChanged = moveSessionKey(session.activeBrowserTabIdByWorktree) || sessionChanged + sessionChanged = moveSessionKey(session.activeTabTypeByWorktree) || sessionChanged + sessionChanged = moveSessionKey(session.activeTabIdByWorktree) || sessionChanged + sessionChanged = + moveSessionKey(session.unifiedTabs, (tabs) => tabs.map(withNewWorktreeId)) || sessionChanged + sessionChanged = + moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged + sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged + sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged + sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged + sessionChanged = + moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged + if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) { + session.activeWorktreeIdsOnShutdown = session.activeWorktreeIdsOnShutdown.map((id) => + id === oldWorktreeId ? newWorktreeId : id + ) + sessionChanged = true + } + if (session.activeWorktreeId === oldWorktreeId) { + session.activeWorktreeId = newWorktreeId + sessionChanged = true + } + if (session.activeWorkspaceKey === oldWorkspaceKey) { + session.activeWorkspaceKey = newWorkspaceKey + sessionChanged = true + } + if (session.sleepingAgentSessionsByPaneKey) { + let sleepingChanged = false + const nextSleeping = { ...session.sleepingAgentSessionsByPaneKey } + for (const [paneKey, record] of Object.entries(nextSleeping)) { + if (record.worktreeId !== oldWorktreeId) { + continue + } + nextSleeping[paneKey] = { ...record, worktreeId: newWorktreeId } + sleepingChanged = true + } + if (sleepingChanged) { + session.sleepingAgentSessionsByPaneKey = nextSleeping + sessionChanged = true + } + } + if (session.terminalSurfaceTombstonesByPaneKey) { + let tombstonesChanged = false + const nextTombstones = { ...session.terminalSurfaceTombstonesByPaneKey } + for (const [paneKey, tombstone] of Object.entries(nextTombstones)) { + if (tombstone.worktreeId !== oldWorktreeId) { + continue + } + nextTombstones[paneKey] = { ...tombstone, worktreeId: newWorktreeId } + tombstonesChanged = true + } + if (tombstonesChanged) { + session.terminalSurfaceTombstonesByPaneKey = nextTombstones + sessionChanged = true + } + } + return sessionChanged + } + + let changed = moveKey(state.worktreeMeta) + // Record the prior id so a session minted under it isn't reaped as an orphan. + const newMeta = state.worktreeMeta[newWorktreeId] + if (newMeta) { + const prior = newMeta.priorWorktreeIds ?? [] + if (!prior.includes(oldWorktreeId)) { + newMeta.priorWorktreeIds = [...prior, oldWorktreeId] + changed = true + } + } + + changed = moveKey(state.worktreeLineageById) || changed + const movedLineage = state.worktreeLineageById[newWorktreeId] + if (movedLineage && movedLineage.worktreeId === oldWorktreeId) { + movedLineage.worktreeId = newWorktreeId + } + // Why: children carry this as parentWorktreeId; keep the denormalized path-derived id consistent (parentWorktreeInstanceId is stable). + for (const lineage of Object.values(state.worktreeLineageById)) { + if (lineage.parentWorktreeId === oldWorktreeId) { + lineage.parentWorktreeId = newWorktreeId + changed = true + } + } + + if (oldWorkspaceKey in state.workspaceLineageByChildKey) { + const lineage = state.workspaceLineageByChildKey[oldWorkspaceKey] + state.workspaceLineageByChildKey[newWorkspaceKey] = { + ...lineage, + childWorkspaceKey: newWorkspaceKey + } + delete state.workspaceLineageByChildKey[oldWorkspaceKey] + changed = true + } + for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + if (lineage.parentWorkspaceKey === oldWorkspaceKey) { + state.workspaceLineageByChildKey[childKey as WorkspaceKey] = { + ...lineage, + parentWorkspaceKey: newWorkspaceKey + } + changed = true + } + } + + changed = migrateSession(state.workspaceSession) || changed + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + changed = migrateSession(session) || changed + } + for (const selectionsByWorktree of Object.values( + state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + changed = moveKey(selectionsByWorktree) || changed + } + const showDotfiles = state.ui?.showDotfilesByWorktree + if (showDotfiles) { + changed = moveKey(showDotfiles) || changed + } + + return changed +} diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts new file mode 100644 index 00000000000..052b2d5cf4f --- /dev/null +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -0,0 +1,121 @@ +import { existsSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { isWslUncPath } from '../../../shared/wsl-paths' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + areTaskSourceContextsEqual, + normalizeStoredTaskSourceContext +} from '../../../shared/task-source-context' +import { + areWorkspaceLinkedItemsEqual, + normalizeWorkspaceLinkedItem +} from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' + +// Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). +// GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. +export const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 +export const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 + +export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { + // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. + state.worktreeMeta ??= {} + const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) + const projectIds = new Set((state.projects ?? []).map((project) => project.id)) + const now = Date.now() + let removed = 0 + for (const key of Object.keys(state.worktreeMeta)) { + // Why: folder-project workspace instances (keyed repoId::path::workspace:) ARE the workspace record, not a checkout row; skip them. + if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) { + continue + } + const separator = key.indexOf('::') + if (separator === -1) { + continue + } + const ownerId = key.slice(0, separator) + const worktreePath = key.slice(separator + 2) + const meta = state.worktreeMeta[key] + const repo = repoById.get(ownerId) + if (repo) { + if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + continue + } + } else if (projectIds.has(ownerId)) { + // Project-owned metas keep their own project/host lifecycle; leave them alone. + continue + } + // Unowned entries (repo removed before metas were pruned) fall through to the same missing-path + idle-grace gate. + if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) { + continue + } + if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) { + continue + } + // Why: WSL worktrees on Windows carry Linux-style paths that Windows existsSync can't probe and would falsely condemn. + if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) { + continue + } + // Why keep timestamp-less entries: without timestamps we can't prove the 30-day grace elapsed (measured dead entries all had them). + // Grace is checked before existsSync so active entries skip the stat fan-out (and its slow-NFS tail). + const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0) + if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) { + continue + } + if (existsSync(worktreePath)) { + continue + } + delete state.worktreeMeta[key] + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + removed++ + } + return removed +} + +export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean { + let changed = false + const rawWorktreeMeta = state.worktreeMeta as unknown + if ( + typeof rawWorktreeMeta !== 'object' || + rawWorktreeMeta === null || + Array.isArray(rawWorktreeMeta) + ) { + state.worktreeMeta = {} + changed = rawWorktreeMeta !== undefined + } + for (const [key, meta] of Object.entries(state.worktreeMeta)) { + // Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta + // keeps timestamp-less keys forever and every downstream consumer trusts the Record type. + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + delete state.worktreeMeta[key] + // Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would + // otherwise re-attach to a worktree recreated at the same repoId::path. + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + changed = true + continue + } + const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) + const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) + const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + linkedWorkItem, + sourceContext + ) + ? sourceContext + : null + if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) { + meta.linkedWorkItem = linkedWorkItem + changed = true + } + if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) { + meta.linkedTaskSourceContext = linkedTaskSourceContext + changed = true + } + } + return changed +} diff --git a/src/main/runtime/mobile-pairing-userdata-path.test.ts b/src/main/runtime/mobile-pairing-userdata-path.test.ts index 416ad545dd3..be13c1ebbc3 100644 --- a/src/main/runtime/mobile-pairing-userdata-path.test.ts +++ b/src/main/runtime/mobile-pairing-userdata-path.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' // Import from the production source of truth so a filename rename can't silently @@ -160,6 +161,46 @@ describe('mobile pairing userData path stability', () => { expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(canonicalKeypair) }) + it('rolls back the first copy when the second file fails to migrate', async () => { + // A half-copied pair would leave the registry without its E2EE key and, worse, + // trip the existing-target guard so the next launch never retries. + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + let copies = 0 + return { + ...actual, + default: actual, + copyFileSync: (source: string, target: string) => { + copies += 1 + if (copies === 2) { + throw new Error('simulated copy failure') + } + actual.copyFileSync(source, target) + } + } + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + appState.userData = lateDir + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), JSON.stringify([])) + writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), JSON.stringify({ v: 1 })) + + expect(() => migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)).not.toThrow() + + expect(errorSpy).toHaveBeenCalled() + expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(false) + } finally { + errorSpy.mockRestore() + vi.doUnmock('node:fs') + } + }) + it('no-ops when the source path equals the canonical path (no rename happened)', async () => { // Case-insensitive filesystems (macOS/Windows) resolve both paths to the same // dir, so migration must be a clean no-op rather than copy a file onto itself.