/* eslint-disable max-lines -- Why: one file keeps the full storage contract (defaults, migration, mutation, flush) reviewable together. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync, realpathSync, statSync, symlinkSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState, Project, ProjectGroup, ProjectHostSetup, Repo, GlobalSettings, TerminalPaneLayoutNode, TerminalTab, WorktreeLineage, WorkspaceLineage, WorkspaceSessionState } from '../shared/types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' import { getDefaultPersistedState, getDefaultWorkspaceSession, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/constants' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host' import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), sshConfigHostsToTargetsMock: vi.fn() })) vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: loadUserSshConfigMock, sshConfigHostsToTargets: sshConfigHostsToTargetsMock })) const TEST_LEAF_1 = '11111111-1111-4111-8111-111111111111' const TEST_LEAF_2 = '22222222-2222-4222-8222-222222222222' const TEST_LEAF_LIVE = '33333333-3333-4333-8333-333333333333' const TEST_LEAF_EXPIRED = '44444444-4444-4444-8444-444444444444' const REORDERED_DEFAULT_WORKSPACE_STATUSES = [ { id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' }, { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, { id: 'in-progress', label: 'In progress', color: 'conductor-progress', icon: 'conductor-progress' }, { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } ] const REORDERED_DONE_DEFAULT_WORKSPACE_STATUSES = [ { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' }, { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, { id: 'in-progress', label: 'In progress', color: 'conductor-progress', icon: 'conductor-progress' }, { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } ] const LEGACY_DEFAULT_WORKSPACE_STATUSES = [ { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }, { id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }, { id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' }, { id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' } ] const WORKFLOW_DEFAULT_WORKSPACE_STATUSES = [ { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }, { id: 'in-progress', label: 'In progress', color: 'conductor-progress', icon: 'conductor-progress' }, { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' } ] const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ trackMock: vi.fn(), getCohortAtEmitMock: vi.fn() })) vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, safeStorage: { isEncryptionAvailable: () => true, encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), decryptString: (ciphertext: Buffer) => { const decoded = ciphertext.toString('utf-8') if (!decoded.startsWith('encrypted:')) { throw new Error('invalid ciphertext') } return decoded.slice('encrypted:'.length) } } })) vi.mock('./telemetry/client', () => ({ track: trackMock })) vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) /** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ async function createStore() { vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() return new Store() } async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: platform }) try { return await fn() } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } } function dataFile(): string { return join(testState.dir, 'orca-data.json') } function writeDataFile(data: unknown): void { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), JSON.stringify(data, null, 2), 'utf-8') } function readDataFile(): unknown { return JSON.parse(readFileSync(dataFile(), 'utf-8')) } function symlinkDirectorySync(target: string, linkPath: string): void { symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir') } function collectPropertyPaths(value: unknown, property: string, prefix = ''): string[] { if (!value || typeof value !== 'object') { return [] } const paths: string[] = [] for (const [key, child] of Object.entries(value)) { const path = prefix ? `${prefix}.${key}` : key if (key === property) { paths.push(path) } paths.push(...collectPropertyPaths(child, property, path)) } return paths } const makeRepo = (overrides: Partial = {}): Repo => ({ id: 'r1', path: '/repo', displayName: 'test', badgeColor: '#fff', addedAt: 1, ...overrides }) const makeProject = (overrides: Partial = {}): Project => ({ id: 'project-1', displayName: 'Project', badgeColor: '#737373', sourceRepoIds: [], createdAt: 1, updatedAt: 1, ...overrides }) const makeProjectHostSetup = (overrides: Partial = {}): ProjectHostSetup => ({ id: 'setup-1', projectId: 'project-1', hostId: 'local', repoId: '', path: '/repo', displayName: 'Project', setupState: 'ready', setupMethod: 'imported-existing-folder', createdAt: 1, updatedAt: 1, ...overrides }) const makeTerminalTab = (overrides: Partial = {}): TerminalTab => ({ id: 'tab1', ptyId: 'pty1', worktreeId: 'repo1::/worktree', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ...overrides }) const makeWorktreeLineage = (overrides: Partial = {}): WorktreeLineage => ({ worktreeId: 'r1::/path/child', worktreeInstanceId: 'child-instance', parentWorktreeId: 'r1::/path/parent', parentWorktreeInstanceId: 'parent-instance', origin: 'manual', capture: { source: 'manual-action', confidence: 'explicit' }, createdAt: 1, ...overrides }) const makeWorkspaceLineage = (overrides: Partial = {}): WorkspaceLineage => ({ childWorkspaceKey: worktreeWorkspaceKey('r1::/path/child'), childInstanceId: 'child-instance', parentWorkspaceKey: folderWorkspaceKey('folder-1'), parentInstanceId: null, origin: 'cli', capture: { source: 'env-workspace', confidence: 'inferred' }, createdAt: 1, ...overrides }) function makeSessionWithTerminalBuffers(): WorkspaceSessionState { return { activeRepoId: 'local-repo', activeWorktreeId: 'local-repo::/local', activeTabId: 'local-tab', tabsByWorktree: { 'local-repo::/local': [ makeTerminalTab({ id: 'local-tab', ptyId: 'local-pty', worktreeId: 'local-repo::/local' }) ], 'remote-repo::/remote': [ makeTerminalTab({ id: 'remote-tab', ptyId: 'remote-pty', worktreeId: 'remote-repo::/remote' }) ] }, terminalLayoutsByTabId: { 'local-tab': { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, buffersByLeafId: { [TEST_LEAF_1]: 'local-scrollback' }, ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty' } }, 'remote-tab': { root: { type: 'leaf', leafId: TEST_LEAF_2 }, activeLeafId: TEST_LEAF_2, expandedLeafId: null, buffersByLeafId: { [TEST_LEAF_2]: 'remote-scrollback' }, ptyIdsByLeafId: { [TEST_LEAF_2]: 'remote-pty' } } } } } function makeSessionWithBrowserHistory(count: number): WorkspaceSessionState { return { activeRepoId: null, activeWorktreeId: null, activeTabId: null, tabsByWorktree: {}, terminalLayoutsByTabId: {}, browserUrlHistory: Array.from({ length: count }, (_, index) => ({ url: `https://example.com/${index}`, normalizedUrl: `https://example.com/${index}`, title: `Example ${index} ${'x'.repeat(200)}`, lastVisitedAt: 1_700_000_000_000 - index, visitCount: 1 })) } } function makeBalancedLegacyPaneLayout(start: number, end: number): TerminalPaneLayoutNode { if (end - start === 1) { return { type: 'leaf', leafId: `pane:${start + 1}` } } const midpoint = Math.floor((start + end) / 2) return { type: 'split', direction: 'horizontal', first: makeBalancedLegacyPaneLayout(start, midpoint), second: makeBalancedLegacyPaneLayout(midpoint, end) } } describe('Store', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) trackMock.mockReset() getCohortAtEmitMock.mockReset() getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) }) // ── 1. Defaults when no file exists ────────────────────────────────── it('returns empty repos when no data file exists', async () => { const store = await createStore() expect(store.getRepos()).toEqual([]) }, 15_000) it('clone-reads and synchronously persists the main-owned Codex reset ledger', async () => { const store = await createStore() const ledger = { version: 1 as const, attempts: [ { idempotencyKey: '11111111-1111-4111-8111-111111111111', expectedScope: { target: { runtime: 'host' as const, wslDistro: null }, accountId: 'account-host', accountRevision: 42, offerRevision: 'v1:offer' }, state: 'providerPending' as const } ] } store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) const firstRead = store.getCodexResetCreditAttemptLedger() firstRead.attempts.splice(0, 1) expect(store.getCodexResetCreditAttemptLedger()).toEqual(ledger) expect((readDataFile() as PersistedState).codexResetCreditAttemptLedger).toEqual(ledger) }) it('rolls the in-memory Codex reset ledger back when its sync flush fails', async () => { const store = await createStore() const before = store.getCodexResetCreditAttemptLedger() vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { throw new Error('disk full') }) expect(() => store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { idempotencyKey: '11111111-1111-4111-8111-111111111111', expectedScope: { target: { runtime: 'host', wslDistro: null }, accountId: 'account-host', accountRevision: 42, offerRevision: 'v1:offer' }, state: 'providerPending' } ] }) ).toThrow('disk full') expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) }) it('preserves a corrupt Codex reset ledger as a fail-closed read error', async () => { writeDataFile({ ...getDefaultPersistedState(testState.dir), codexResetCreditAttemptLedger: { version: 1, attempts: [{ state: 'providerPending' }] } }) const store = await createStore() expect(() => store.getCodexResetCreditAttemptLedger()).toThrow( 'Codex reset-credit attempt ledger is corrupt' ) }) it('does not restore a terminal tab after its durable close flush returns', async () => { const store = await createStore() const worktreeId = 'repo-1::/tmp/worktree-1' const tabId = 'terminal-1' const session: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), activeWorktreeId: worktreeId, activeTabId: tabId, tabsByWorktree: { [worktreeId]: [ { id: tabId, ptyId: 'pty-1', worktreeId, title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1 } ] }, terminalLayoutsByTabId: { [tabId]: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } } }, activeTabIdByWorktree: { [worktreeId]: tabId }, defaultTerminalTabsAppliedByWorktreeId: { [worktreeId]: true } } store.setWorkspaceSession(session) store.flushOrThrow() const closed = closeTerminalTabInWorkspaceSession( store.getWorkspaceSession(), worktreeId, tabId ) store.setWorkspaceSession(closed.session) store.flushOrThrow() const reloaded = await createStore() expect(reloaded.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId[tabId]).toBeUndefined() expect( reloaded.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] ).toBe(true) }) it('loads state from an explicit profile data file path', async () => { const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') const profileDataFile = join(profileDataDirectory, 'orca-data.json') mkdirSync(profileDataDirectory, { recursive: true }) writeDataFile({ schemaVersion: 1, repos: [makeRepo({ id: 'legacy-root-repo', path: '/legacy' })] }) writeFileSync( profileDataFile, JSON.stringify({ schemaVersion: 1, repos: [makeRepo({ id: 'profile-repo', path: '/profile' })] }), 'utf-8' ) vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) expect(store.getRepos().map((repo) => repo.id)).toEqual(['profile-repo']) }, 15_000) it('backfills project host setup compatibility records from legacy repos on load', async () => { writeDataFile({ schemaVersion: 1, repos: [ makeRepo({ id: 'local-repo', path: '/Users/alice/orca', displayName: 'Orca', upstream: { owner: 'StablyAI', repo: 'Orca' } }), makeRepo({ id: 'remote-repo', path: '/home/alice/orca', displayName: 'orca', connectionId: 'gpu-vm', upstream: { owner: 'stablyai', repo: 'orca' } }) ] }) const store = await createStore() expect(store.getProjects()).toEqual([ expect.objectContaining({ id: 'github:stablyai/orca', sourceRepoIds: ['local-repo', 'remote-repo'] }) ]) expect(store.getProjectHostSetups()).toEqual([ expect.objectContaining({ id: 'local-repo', projectId: 'github:stablyai/orca', hostId: 'local', path: '/Users/alice/orca' }), expect.objectContaining({ id: 'remote-repo', projectId: 'github:stablyai/orca', hostId: 'ssh:gpu-vm', path: '/home/alice/orca' }) ]) store.flush() const persisted = readDataFile() as PersistedState expect(persisted.projects).toEqual(store.getProjects()) expect(persisted.projectHostSetups).toEqual(store.getProjectHostSetups()) }) it('preserves independent project host setup records on load', async () => { const independentProject = makeProject({ id: 'cloud-project', displayName: 'Cloud Project' }) const independentSetup = makeProjectHostSetup({ id: 'cloud-project::gpu-vm', projectId: independentProject.id, hostId: 'runtime:gpu-vm', repoId: '', path: '/srv/cloud-project', displayName: 'GPU VM' }) writeDataFile({ ...getDefaultPersistedState(testState.dir), repos: [makeRepo({ id: 'r1', path: '/repo', displayName: 'Repo' })], projects: [independentProject], projectHostSetups: [independentSetup] }) const store = await createStore() expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([ 'r1', 'cloud-project::gpu-vm' ]) store.flush() const persisted = readDataFile() as PersistedState expect(persisted.projectHostSetups).toContainEqual(independentSetup) }) it('updates and persists a project Windows runtime preference', async () => { const project = makeProject({ id: 'project-1', sourceRepoIds: ['r1'], localWindowsRuntimePreference: { kind: 'inherit-global' } }) writeDataFile({ ...getDefaultPersistedState(testState.dir), projects: [project], projectHostSetups: [ makeProjectHostSetup({ id: 'setup-1', projectId: project.id, repoId: '' }) ] }) const store = await createStore() const updated = store.updateProject('project-1', { localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) expect(updated?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) store.flush() const reloaded = await createStore() expect(reloaded.getProjects()[0]?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) }) it('migrates legacy WSL agent settings into the global Windows runtime default', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } }) const store = await createStore() expect(store.getSettings().localWindowsRuntimeDefault).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) store.flush() expect((readDataFile() as PersistedState).settings.localWindowsRuntimeDefault).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) }) it('migrates the legacy host account-runtime default to auto once', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { localAccountRuntime: 'host' } }) const store = await createStore() expect(store.getSettings().localAccountRuntime).toBe('auto') expect(store.getSettings().localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) store.flush() const persisted = (readDataFile() as PersistedState).settings expect(persisted.localAccountRuntime).toBe('auto') expect(persisted.localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) }) it('preserves an explicit WSL account-runtime pin through the migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' } }) const store = await createStore() expect(store.getSettings().localAccountRuntime).toBe('wsl') expect(store.getSettings().localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) }) it('does not re-flip an explicit host pin chosen after migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { localAccountRuntime: 'host', localAccountRuntimeDefaultedToAutoForAllUsers: true } }) const store = await createStore() expect(store.getSettings().localAccountRuntime).toBe('host') }) it('returns default settings when no data file exists', async () => { const store = await createStore() const settings = store.getSettings() expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(settings.sourceControlGroupOrder).toBe('changes-first') expect(settings.theme).toBe('system') expect(settings.appIcon).toBe('classic') expect(settings.appFontFamily).toBe('Geist') expect(settings.editorAutoSave).toBe(false) expect(settings.editorAutoSaveDelayMs).toBe(1000) expect(settings.terminalFontSize).toBe(14) expect(settings.terminalFontWeight).toBe(500) expect(settings.terminalScrollSensitivity).toBe(1.15) expect(settings.terminalFastScrollSensitivity).toBe(5) expect(settings.terminalTuiScrollSensitivity).toBe(1) expect(settings.localAccountRuntime).toBe('auto') expect(settings.localAccountRuntimeDefaultedToAutoForAllUsers).toBe(true) expect(settings.terminalTuiScrollSensitivityDefaultedToOne).toBe(true) expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) expect(settings.showAutomationsButton).toBe(true) expect(settings.visibleTaskProviders).toEqual(['github', 'gitlab', 'linear', 'jira']) expect(settings.openInApplications).toEqual([ { id: 'vscode', label: 'VS Code', command: 'code' } ]) expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(settings.experimentalTerminalAttention).toBe(false) expect(settings.experimentalNewWorktreeCardStyle).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() expect(settings.notifications.customSoundVolume).toBe(100) expect(settings.notifications.suppressWhenFocused).toBe(true) }) it('repairs a persisted terminal line height outside xterm bounds', async () => { const persisted = getDefaultPersistedState(testState.dir) writeDataFile({ ...persisted, settings: { ...persisted.settings, terminalLineHeight: 0.85 } }) const store = await createStore() expect(store.getSettings().terminalLineHeight).toBe(1) store.flush() expect((readDataFile() as PersistedState).settings.terminalLineHeight).toBe(1) }) it('returns default UI state when no data file exists', async () => { const store = await createStore() const ui = store.getUI() expect(ui.sidebarWidth).toBe(280) expect(ui.rightSidebarOpen).toBe(true) expect(ui.rightSidebarTab).toBe('explorer') expect(ui.groupBy).toBe('repo') expect(ui.lastActiveRepoId).toBeNull() expect(ui.dismissedUpdateVersion).toBeNull() expect(ui.lastUpdateCheckAt).toBeNull() expect(ui.setupGuideSidebarDismissed).toBe(false) expect(ui.setupGuideBrowserMilestoneMigrated).toBe(true) expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false) // Why: brand-new profiles never saw remaining-as-default. expect(ui.usagePercentageDisplayChangeNoticeDismissed).toBe(true) }) it('surfaces the usage percentage display change notice for upgraded profiles', async () => { const persisted = getDefaultPersistedState(testState.dir) writeDataFile({ ...persisted, onboarding: { ...persisted.onboarding, closedAt: 1, outcome: 'completed' }, ui: { ...persisted.ui, // Why: omit the notice key so load resolves eligibility for existing profiles. usagePercentageDisplayChangeNoticeDismissed: undefined } }) const store = await createStore() expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(false) }) it('keeps the usage percentage display change notice dismissed when remaining was chosen', async () => { const persisted = getDefaultPersistedState(testState.dir) writeDataFile({ ...persisted, onboarding: { ...persisted.onboarding, closedAt: 1, outcome: 'completed' }, ui: { ...persisted.ui, usagePercentageDisplay: 'remaining', usagePercentageDisplayChangeNoticeDismissed: undefined } }) const store = await createStore() expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(true) }) it('defaults minimizeToTrayOnClose to false when unset', async () => { const store = await createStore() expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) it('coerces loaded minimizeToTrayOnClose to false unless stored as true', async () => { writeDataFile({ ...getDefaultPersistedState(testState.dir), settings: { minimizeToTrayOnClose: 'true' as unknown as boolean } }) const store = await createStore() expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) it('persists minimizeToTrayOnClose true/false round-trip', async () => { const store = await createStore() store.updateSettings({ minimizeToTrayOnClose: true }) expect(store.getSettings().minimizeToTrayOnClose).toBe(true) store.flush() expect((readDataFile() as PersistedState).settings.minimizeToTrayOnClose).toBe(true) store.updateSettings({ minimizeToTrayOnClose: false }) expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) it('persists native chat session options with per-model isolation', async () => { const store = await createStore() store.updateSettings({ nativeChatSessionOptions: { claude: { model: 'opus', valuesByModel: { opus: { effort: 'xhigh', fastMode: true }, sonnet: { effort: 'medium' } } } } }) store.flush() const reloaded = await createStore() expect(reloaded.getSettings().nativeChatSessionOptions?.claude).toEqual({ model: 'opus', valuesByModel: { opus: { effort: 'xhigh', fastMode: true }, sonnet: { effort: 'medium' } } }) }) it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => { const store = await createStore() // Why: a renderer-supplied non-bool must never persist as truthy and later read as "tray-minimize on". store.updateSettings({ minimizeToTrayOnClose: 'true' as unknown as boolean }) expect(store.getSettings().minimizeToTrayOnClose).toBe(false) store.updateSettings({ minimizeToTrayOnClose: 1 as unknown as boolean }) expect(store.getSettings().minimizeToTrayOnClose).toBe(false) store.updateSettings({ minimizeToTrayOnClose: null as unknown as boolean }) expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) it('defaults the menu bar icon on regardless of platform', async () => { await withPlatform('darwin', async () => { const store = await createStore() expect(store.getSettings().showMenuBarIcon).toBe(true) }) await withPlatform('linux', async () => { const store = await createStore() expect(store.getSettings().showMenuBarIcon).toBe(true) }) }) it('enables the menu bar icon when an existing macOS profile has no stored value', async () => { await withPlatform('darwin', async () => { const persisted = getDefaultPersistedState(testState.dir) delete (persisted.settings as Partial).showMenuBarIcon writeDataFile(persisted) const store = await createStore() expect(store.getSettings().showMenuBarIcon).toBe(true) }) }) it('persists an explicit macOS menu bar opt-out', async () => { await withPlatform('darwin', async () => { const store = await createStore() store.updateSettings({ showMenuBarIcon: false }) store.flush() expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) }) }) it('normalizes menu bar icon writes to a strict boolean', async () => { await withPlatform('darwin', async () => { const store = await createStore() store.updateSettings({ showMenuBarIcon: 'true' as unknown as boolean }) expect(store.getSettings().showMenuBarIcon).toBe(false) store.updateSettings({ showMenuBarIcon: true }) expect(store.getSettings().showMenuBarIcon).toBe(true) }) }) it('round-trips a macOS menu bar opt-out through a non-mac host unchanged', async () => { await withPlatform('darwin', async () => { const store = await createStore() store.updateSettings({ showMenuBarIcon: false }) store.flush() }) // Why: a profile opened on another OS must not rewrite the mac-only preference on flush. await withPlatform('win32', async () => { const store = await createStore() store.updateSettings({ minimizeToTrayOnClose: true }) store.flush() expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) }) await withPlatform('darwin', async () => { expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) }) }) it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => { const store = await createStore() expect(store.getUI().trayMinimizeNoticeShown).toBe(false) store.updateUI({ trayMinimizeNoticeShown: true }) expect(store.getUI().trayMinimizeNoticeShown).toBe(true) store.flush() const reloaded = await createStore() expect(reloaded.getUI().trayMinimizeNoticeShown).toBe(true) }) it('hides the setup guide sidebar entry for existing users backfilled as completed', async () => { writeDataFile({ schemaVersion: 1, ui: {} }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.closedAt).not.toBeNull() expect(onboarding.outcome).toBe('completed') expect(onboarding.lastCompletedStep).toBe(ONBOARDING_FINAL_STEP) expect(store.getUI().setupGuideSidebarDismissed).toBe(true) expect(store.getUI().setupGuideBrowserMilestoneMigrated).toBe(false) expect(store.getUI().setupGuideBrowserMilestoneLegacyComplete).toBe(false) }) it('persists the existing-user onboarding backfill back to disk', async () => { // Why: the upgrade-cohort backfill is derived at load; assert it round-trips through a write intact (load-time scheduleSave via loadNeedsSave, no manual flush). writeDataFile({ schemaVersion: 1, ui: {} }) const store = await createStore() store.flush() const persisted = readDataFile() as { onboarding?: { closedAt: number | null; outcome: string | null; lastCompletedStep: number } ui?: { setupGuideSidebarDismissed?: boolean } } expect(persisted.onboarding?.closedAt).not.toBeNull() expect(persisted.onboarding?.outcome).toBe('completed') expect(persisted.onboarding?.lastCompletedStep).toBe(ONBOARDING_FINAL_STEP) expect(persisted.ui?.setupGuideSidebarDismissed).toBe(true) }) it('keeps the setup guide sidebar entry available while onboarding is open', async () => { writeDataFile({ onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: null, outcome: null, lastCompletedStep: -1, checklist: {} }, ui: {} }) const store = await createStore() expect(store.getOnboarding().closedAt).toBeNull() expect(store.getUI().setupGuideSidebarDismissed).toBe(false) }) it('keeps new worktree card style off while onboarding is open', async () => { writeDataFile({ settings: {}, onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: null, outcome: null, lastCompletedStep: -1, checklist: {} }, ui: {} }) const store = await createStore() expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves explicit new worktree card style opt-out while onboarding is open', async () => { writeDataFile({ settings: { experimentalNewWorktreeCardStyle: false }, onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: null, outcome: null, lastCompletedStep: -1, checklist: {} }, ui: {} }) const store = await createStore() expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves explicit new worktree card style opt-in on load', async () => { writeDataFile({ schemaVersion: 1, settings: { experimentalNewWorktreeCardStyle: true }, ui: {} }) const store = await createStore() expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) }) it('keeps new worktree card style off for existing users backfilled as completed', async () => { writeDataFile({ schemaVersion: 1, settings: {}, ui: {} }) const store = await createStore() expect(store.getOnboarding().closedAt).not.toBeNull() expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('treats persisted false setup guide sidebar dismissal as stale once onboarding is closed', async () => { writeDataFile({ onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: 123, outcome: 'dismissed', lastCompletedStep: 2, checklist: {} }, ui: { setupGuideSidebarDismissed: false } }) const store = await createStore() expect(store.getUI().setupGuideSidebarDismissed).toBe(true) }) it('keeps malformed completed onboarding closed for the setup guide sidebar gate', async () => { writeDataFile({ onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: 'yesterday', outcome: 'completed', lastCompletedStep: ONBOARDING_FINAL_STEP, checklist: {} }, ui: { setupGuideSidebarDismissed: false } }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.closedAt).not.toBeNull() expect(onboarding.outcome).toBe('completed') expect(onboarding.lastCompletedStep).toBe(ONBOARDING_FINAL_STEP) expect(store.getUI().setupGuideSidebarDismissed).toBe(true) }) it('does not reopen the setup guide sidebar when closed onboarding has a null timestamp', async () => { writeDataFile({ onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: null, outcome: 'dismissed', lastCompletedStep: 1, checklist: {} }, ui: {} }) const store = await createStore() expect(store.getOnboarding().closedAt).not.toBeNull() expect(store.getUI().setupGuideSidebarDismissed).toBe(true) }) it('recovers a close timestamp when closed onboarding omits the closedAt key', async () => { // Why: a block missing `closedAt` entirely (vs explicit null) must still stay closed via outcome recovery, guarding the `'closedAt' in raw` branch. writeDataFile({ onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, outcome: 'completed', lastCompletedStep: ONBOARDING_FINAL_STEP, checklist: {} }, ui: {} }) const store = await createStore() expect(store.getOnboarding().closedAt).not.toBeNull() expect(store.getUI().setupGuideSidebarDismissed).toBe(true) }) it('does not mutate gate fields for a consistent closed-onboarding existing user', async () => { // Why: the gate must be idempotent — a closed+completed user round-trips unchanged, and the backfill must not stomp closedAt with a fresh Date.now(). const consistent = { onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: 123, outcome: 'completed', lastCompletedStep: ONBOARDING_FINAL_STEP, checklist: {} }, ui: { setupGuideSidebarDismissed: true } } writeDataFile(consistent) const store = await createStore() expect(store.getUI().setupGuideSidebarDismissed).toBe(true) store.flush() const persisted = readDataFile() as typeof consistent // Flushing the loaded state preserves the persisted gate fields verbatim. expect(persisted.onboarding.closedAt).toBe(123) expect(persisted.onboarding.outcome).toBe('completed') expect(persisted.ui.setupGuideSidebarDismissed).toBe(true) }) it.each([ [3, 2], [4, 2], [5, 3], [6, 3], [9, 3] ])( 'migrates unversioned seven-step onboarding progress %i before applying the current step bound', async (legacyStep, expectedStep) => { writeDataFile({ onboarding: { closedAt: null, outcome: null, lastCompletedStep: legacyStep, checklist: {} } }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.flowVersion).toBe(ONBOARDING_FLOW_VERSION) expect(onboarding.lastCompletedStep).toBe(expectedStep) expect(onboarding.closedAt).toBeNull() expect(onboarding.outcome).toBeNull() } ) it.each([ [3, 2], [4, 3], [5, 3], [9, 3] ])( 'migrates versioned five-step onboarding progress %i before applying the current step bound', async (legacyStep, expectedStep) => { writeDataFile({ onboarding: { flowVersion: 2, closedAt: null, outcome: null, lastCompletedStep: legacyStep, checklist: {} } }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.flowVersion).toBe(ONBOARDING_FLOW_VERSION) expect(onboarding.lastCompletedStep).toBe(expectedStep) expect(onboarding.closedAt).toBeNull() expect(onboarding.outcome).toBeNull() } ) it.each([ [3, 3], [4, 4], [9, 4] ])( 'migrates versioned four-step onboarding progress %i around the inserted Windows step', async (legacyStep, expectedStep) => { writeDataFile({ onboarding: { flowVersion: 3, closedAt: null, outcome: null, lastCompletedStep: legacyStep, checklist: {} } }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.flowVersion).toBe(ONBOARDING_FLOW_VERSION) expect(onboarding.lastCompletedStep).toBe(expectedStep) expect(onboarding.closedAt).toBeNull() expect(onboarding.outcome).toBeNull() } ) it('keeps current onboarding progress marked as the five-step flow', async () => { writeDataFile({ onboarding: { flowVersion: ONBOARDING_FLOW_VERSION, closedAt: null, outcome: null, lastCompletedStep: 3, checklist: {} } }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.flowVersion).toBe(ONBOARDING_FLOW_VERSION) expect(onboarding.lastCompletedStep).toBe(3) }) it('migrates legacy completed onboarding progress to the current final step', async () => { writeDataFile({ onboarding: { closedAt: 1, outcome: 'completed', lastCompletedStep: 7, checklist: {} } }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.flowVersion).toBe(ONBOARDING_FLOW_VERSION) expect(onboarding.outcome).toBe('completed') expect(onboarding.lastCompletedStep).toBe(ONBOARDING_FINAL_STEP) }) it.each([ [{ outcome: 'completed', lastCompletedStep: 7 }, 'completed', ONBOARDING_FINAL_STEP], [{ closedAt: null, outcome: 'dismissed', lastCompletedStep: 2 }, 'dismissed', 2], [ { closedAt: 'invalid', outcome: 'completed', lastCompletedStep: 7 }, 'completed', ONBOARDING_FINAL_STEP ] ] as const)( 'keeps closed onboarding closed when closedAt is missing or malformed', async (onboardingInput, expectedOutcome, expectedStep) => { writeDataFile({ onboarding: { checklist: {}, ...onboardingInput } }) const store = await createStore() const onboarding = store.getOnboarding() expect(onboarding.closedAt).toEqual(expect.any(Number)) expect(onboarding.outcome).toBe(expectedOutcome) expect(onboarding.lastCompletedStep).toBe(expectedStep) } ) it('preserves legacy none grouping as ungrouped workspaces', async () => { writeDataFile({ schemaVersion: 1, ui: { groupBy: 'none' } }) const store = await createStore() expect(store.getUI().groupBy).toBe('none') }) it('normalizes interim flat grouping back to none', async () => { writeDataFile({ schemaVersion: 1, ui: { groupBy: 'flat' } }) const store = await createStore() expect(store.getUI().groupBy).toBe('none') }) it('preserves explicit workspace status grouping', async () => { writeDataFile({ schemaVersion: 1, ui: { groupBy: 'workspace-status' } }) const store = await createStore() expect(store.getUI().groupBy).toBe('workspace-status') }) it('defaults projectOrderBy to manual when absent, even with recent sortBy', async () => { writeDataFile({ schemaVersion: 1, ui: { sortBy: 'recent' } }) const store = await createStore() expect(store.getUI().projectOrderBy).toBe('manual') }) it('falls back invalid projectOrderBy to manual', async () => { writeDataFile({ schemaVersion: 1, ui: { projectOrderBy: 'bogus' } }) const store = await createStore() expect(store.getUI().projectOrderBy).toBe('manual') }) it('preserves and round-trips an explicit recent projectOrderBy', async () => { writeDataFile({ schemaVersion: 1, ui: { projectOrderBy: 'recent' } }) const store = await createStore() expect(store.getUI().projectOrderBy).toBe('recent') store.updateUI({ projectOrderBy: 'manual' }) expect(store.getUI().projectOrderBy).toBe('manual') }) // ── 2. Load from existing valid file ───────────────────────────────── it('reads repos from an existing data file', async () => { // Why: hydration serves the persisted username without spawning git/gh (issue #7225); resolution happens in background enrichment. const repo = makeRepo({ gitUsername: 'testuser' }) writeDataFile({ schemaVersion: 1, repos: [repo], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() const repos = store.getRepos() expect(repos).toHaveLength(1) expect(repos[0].id).toBe('r1') expect(repos[0].gitUsername).toBe('testuser') }) it('normalizes legacy remote workspace sync fields on SSH targets', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {}, sshTargets: [ { id: 'ssh-disabled-legacy-grace', label: 'Disabled legacy grace', host: 'disabled.example.com', port: 22, username: 'dev', remoteWorkspaceSyncEnabled: false, remoteWorkspaceSyncGracePeriodSeconds: 0 }, { id: 'ssh-enabled-legacy-grace', label: 'Enabled legacy grace', host: 'enabled.example.com', port: 22, username: 'dev', remoteWorkspaceSyncEnabled: true, remoteWorkspaceSyncGracePeriodSeconds: 0 }, { id: 'ssh-synced-grace-wins-over-relay', label: 'Synced grace wins', host: 'new.example.com', port: 22, username: 'dev', relayGracePeriodSeconds: 120, remoteWorkspaceSyncEnabled: true, remoteWorkspaceSyncGracePeriodSeconds: 0 }, { id: 'ssh-form-default-relay-with-unlimited-sync', label: 'Form-default relay with unlimited sync', host: 'unlimited.example.com', port: 22, username: 'dev', relayGracePeriodSeconds: LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, remoteWorkspaceSyncEnabled: true, remoteWorkspaceSyncGracePeriodSeconds: 0 }, { id: 'ssh-form-default-relay', label: 'Form-default relay', host: 'form-default.example.com', port: 22, username: 'dev', relayGracePeriodSeconds: LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } ] }) const store = await createStore() const targets = store.getSshTargets() expect(targets[0]).not.toHaveProperty('relayGracePeriodSeconds') expect(targets[1].relayGracePeriodSeconds).toBe(0) expect(targets[2].relayGracePeriodSeconds).toBe(0) expect(targets[3].relayGracePeriodSeconds).toBe(0) expect(targets[4]).not.toHaveProperty('relayGracePeriodSeconds') for (const target of targets) { expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled') expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds') } store.flush() const persisted = readDataFile() as { sshTargets?: Record[] } expect(persisted.sshTargets?.[0]).not.toHaveProperty('relayGracePeriodSeconds') expect(persisted.sshTargets?.[1]?.relayGracePeriodSeconds).toBe(0) expect(persisted.sshTargets?.[2]?.relayGracePeriodSeconds).toBe(0) expect(persisted.sshTargets?.[3]?.relayGracePeriodSeconds).toBe(0) expect(persisted.sshTargets?.[4]).not.toHaveProperty('relayGracePeriodSeconds') for (const target of persisted.sshTargets ?? []) { expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled') expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds') } }) it('drops the legacy SSH relay default when updating targets', async () => { const store = await createStore() store.addSshTarget({ id: 'ssh-update-legacy-default', label: 'Update legacy default', host: 'update-default.example.com', port: 22, username: 'dev' }) const updated = store.updateSshTarget('ssh-update-legacy-default', { relayGracePeriodSeconds: LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS }) expect(updated).not.toHaveProperty('relayGracePeriodSeconds') expect(store.getSshTarget('ssh-update-legacy-default')).not.toHaveProperty( 'relayGracePeriodSeconds' ) store.flush() const persisted = readDataFile() as { sshTargets?: Record[] } const onDisk = persisted.sshTargets?.find((t) => t.id === 'ssh-update-legacy-default') expect(onDisk).not.toHaveProperty('relayGracePeriodSeconds') }) it('persists the SSH target source field through add, update, and disk round-trip', async () => { const store = await createStore() store.addSshTarget({ id: 'ssh-src-1', label: 'cluster', configHost: 'cluster', host: '10.0.0.5', port: 2200, username: 'dev', source: 'ssh-config' }) // normalizeSshTarget must not strip `source` on update and the new port must take effect (persistence-layer guard for #4684 item #1). const updated = store.updateSshTarget('ssh-src-1', { port: 2222, source: 'ssh-config' }) expect(updated?.port).toBe(2222) expect(updated?.source).toBe('ssh-config') expect(store.getSshTarget('ssh-src-1')?.source).toBe('ssh-config') expect(store.getSshTarget('ssh-src-1')?.port).toBe(2222) store.flush() const persisted = readDataFile() as { sshTargets?: Record[] } const onDisk = persisted.sshTargets?.find((t) => t.id === 'ssh-src-1') expect(onDisk?.source).toBe('ssh-config') expect(onDisk?.port).toBe(2222) }) it('persists only explicit SSH connection reuse opt-outs', async () => { const store = await createStore() store.addSshTarget({ id: 'ssh-reuse-default', label: 'Default reuse', host: 'default.example.com', port: 22, username: 'dev', systemSshConnectionReuse: true }) store.addSshTarget({ id: 'ssh-reuse-off', label: 'Reuse disabled', host: 'legacy.example.com', port: 22, username: 'dev', systemSshConnectionReuse: false }) expect(store.getSshTarget('ssh-reuse-default')).not.toHaveProperty('systemSshConnectionReuse') expect(store.getSshTarget('ssh-reuse-off')?.systemSshConnectionReuse).toBe(false) store.flush() const persistedBeforeUpdate = readDataFile() as { sshTargets?: Record[] } const defaultTarget = persistedBeforeUpdate.sshTargets?.find( (t) => t.id === 'ssh-reuse-default' ) const disabledTarget = persistedBeforeUpdate.sshTargets?.find((t) => t.id === 'ssh-reuse-off') expect(defaultTarget).not.toHaveProperty('systemSshConnectionReuse') expect(disabledTarget?.systemSshConnectionReuse).toBe(false) const updated = store.updateSshTarget('ssh-reuse-off', { systemSshConnectionReuse: undefined }) expect(updated).not.toHaveProperty('systemSshConnectionReuse') store.flush() const persisted = readDataFile() as { sshTargets?: Record[] } const updatedTarget = persisted.sshTargets?.find((t) => t.id === 'ssh-reuse-off') expect(updatedTarget).not.toHaveProperty('systemSshConnectionReuse') }) it('drops retired per-target SSH terminal source-credit selections', async () => { const store = await createStore() store.addSshTarget({ id: 'ssh-source-credit-on', label: 'Noisy build host', host: 'build.example.com', port: 22, username: 'dev', experimentalPtySourceCreditV1: true } as never) expect(store.getSshTarget('ssh-source-credit-on')).not.toHaveProperty( 'experimentalPtySourceCreditV1' ) store.flush() const persisted = readDataFile() as { sshTargets?: Record[] } const target = persisted.sshTargets?.find((entry) => entry.id === 'ssh-source-credit-on') expect(target).not.toHaveProperty('experimentalPtySourceCreditV1') }) it('upserts ~/.ssh/config through the real store: rotated port updates in place and persists', async () => { loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) const candidate = (port: number, id: string) => [ { id, label: 'cluster', configHost: 'cluster', host: '10.0.0.5', port, username: 'dev' } ] const store = await createStore() const sshStore = new SshConnectionStore(store) // First sync inserts the config host, stamped as config-managed. sshConfigHostsToTargetsMock.mockReturnValue(candidate(2200, 'ssh-cfg-1')) const inserted = sshStore.importFromSshConfig() expect(inserted).toHaveLength(1) expect(inserted[0]?.source).toBe('ssh-config') expect(inserted[0]?.port).toBe(2200) // Rotated port: upsert updates the same target in place and normalizeSshTarget must keep `source` (no false re-derive into a permanently-dirty state). sshConfigHostsToTargetsMock.mockReturnValue(candidate(2222, 'ssh-cfg-2')) const changed = sshStore.importFromSshConfig() expect(changed).toHaveLength(1) expect(changed[0]?.port).toBe(2222) expect(changed[0]?.source).toBe('ssh-config') // A third identical sync is a no-op — repeated auto-sync on every pane open writes nothing. expect(sshStore.importFromSshConfig()).toHaveLength(0) // Exactly one cluster target on disk with the rotated port and source kept. store.flush() const onDisk = (readDataFile() as { sshTargets?: Record[] }).sshTargets const clusterTargets = (onDisk ?? []).filter((t) => t.configHost === 'cluster') expect(clusterTargets).toHaveLength(1) expect(clusterTargets[0]?.port).toBe(2222) expect(clusterTargets[0]?.source).toBe('ssh-config') // Survives a fresh load from the same data file. const reloaded = await createStore() const reloadedCluster = reloaded.getSshTargets().find((t) => t.configHost === 'cluster') expect(reloadedCluster?.port).toBe(2222) expect(reloadedCluster?.source).toBe('ssh-config') }) it('drops malformed migration-unsupported PTY entries on load', async () => { const repo = makeRepo() writeDataFile({ schemaVersion: 1, repos: [repo], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {}, migrationUnsupportedPtyEntries: {} }) const store = await createStore() expect(store.getRepos()).toHaveLength(1) }) it('remaps persisted agent acknowledgement pane keys when terminal leaves migrate to UUIDs', async () => { const acknowledgedAt = 1_700_000_000_000 writeDataFile({ schemaVersion: 1, repos: [makeRepo()], worktreeMeta: {}, settings: {}, ui: { acknowledgedAgentsByPaneKey: { 'tab1:0': acknowledgedAt, 'tab1:pane:1': acknowledgedAt - 1_000, 'other-tab:0': acknowledgedAt - 2_000 } }, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'repo1::/worktree', activeTabId: 'tab1', tabsByWorktree: { 'repo1::/worktree': [ makeTerminalTab({ id: 'tab1', ptyId: 'pty1', worktreeId: 'repo1::/worktree' }) ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: '0' }, second: { type: 'leaf', leafId: 'pane:1' } }, activeLeafId: '0', expandedLeafId: null, ptyIdsByLeafId: { '0': 'pty1', 'pane:1': 'pty2' } } } } }) const store = await createStore() const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 const migratedLeafIds = Object.keys(layout.ptyIdsByLeafId ?? {}) expect(migratedLeafIds).toHaveLength(2) expect(migratedLeafIds.every(isTerminalLeafId)).toBe(true) const ui = store.getUI() expect(ui.acknowledgedAgentsByPaneKey).toEqual({ [makePaneKey('tab1', migratedLeafIds[0])]: acknowledgedAt, [makePaneKey('tab1', migratedLeafIds[1])]: acknowledgedAt - 1_000, 'other-tab:0': acknowledgedAt - 2_000 }) }) it('keeps the newest acknowledgement when legacy and migrated pane keys collide', async () => { const legacyAcknowledgedAt = 1_700_000_000_000 const migratedAcknowledgedAt = legacyAcknowledgedAt + 5_000 const migratedPaneKey = makePaneKey('tab1', TEST_LEAF_1) writeDataFile({ schemaVersion: 1, repos: [makeRepo()], worktreeMeta: {}, settings: {}, ui: { acknowledgedAgentsByPaneKey: { 'tab1:0': legacyAcknowledgedAt, [migratedPaneKey]: migratedAcknowledgedAt } }, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'repo1::/worktree', activeTabId: 'tab1', tabsByWorktree: { 'repo1::/worktree': [ makeTerminalTab({ id: 'tab1', ptyId: 'pty1', worktreeId: 'repo1::/worktree' }) ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty1' } } } } }) const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'repo1::/worktree', activeTabId: 'tab1', tabsByWorktree: { 'repo1::/worktree': [ makeTerminalTab({ id: 'tab1', ptyId: 'pty1', worktreeId: 'repo1::/worktree' }) ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: '0' }, activeLeafId: '0', expandedLeafId: null, ptyIdsByLeafId: { '0': 'pty1' } } } }) expect(store.getUI().acknowledgedAgentsByPaneKey).toEqual({ [migratedPaneKey]: migratedAcknowledgedAt }) }) it('can clear an automation back to the project default branch', async () => { const store = await createStore() store.addRepo(makeRepo({ worktreeBaseRef: 'origin/main' })) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', baseBranch: 'origin/release', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const updated = store.updateAutomation(automation.id, { baseBranch: null }) expect(updated.baseBranch).toBeNull() store.flush() const persisted = readDataFile() as { automations: { baseBranch: string | null }[] } expect(persisted.automations[0].baseBranch).toBeNull() }) it('persists session reuse only for existing-workspace automations', async () => { const store = await createStore() store.addRepo(makeRepo()) const existingWorkspace = store.createAutomation({ name: 'Digest', prompt: 'Summarize changes', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', reuseSession: true, timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const newPerRun = store.createAutomation({ name: 'Fresh', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', reuseSession: true, timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) expect(existingWorkspace.reuseSession).toBe(true) expect(newPerRun.reuseSession).toBe(false) expect( store.updateAutomation(existingWorkspace.id, { workspaceMode: 'new_per_run' }).reuseSession ).toBe(false) const persisted = readDataFile() as { automations: Record[] } delete persisted.automations[0].reuseSession writeDataFile(persisted) const reloaded = await createStore() expect(reloaded.listAutomations()[0].reuseSession).toBe(false) }) it('persists setup decisions only for new-per-run automations', async () => { const store = await createStore() store.addRepo(makeRepo()) const newPerRun = store.createAutomation({ name: 'Fresh', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', setupDecision: 'run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const existing = store.createAutomation({ name: 'Reuse', prompt: 'Summarize changes', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', setupDecision: 'run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) expect(newPerRun.setupDecision).toBe('run') expect(existing.setupDecision).toBeUndefined() const skipped = store.updateAutomation(newPerRun.id, { setupDecision: 'skip' }) const switchedToExisting = store.updateAutomation(newPerRun.id, { workspaceMode: 'existing', workspaceId: 'wt1' }) expect(skipped.setupDecision).toBe('skip') expect(switchedToExisting.setupDecision).toBeUndefined() expect( store.updateAutomation(existing.id, { workspaceMode: 'new_per_run', setupDecision: 'run' }) .setupDecision ).toBe('run') }) it('derives automation source and run contexts from the project host setup', async () => { const store = await createStore() store.addRepo( makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' }, connectionId: 'builder' }) ) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) expect(automation.runContext).toMatchObject({ kind: 'workspace-run', projectId: 'github:stablyai/orca', hostId: toSshExecutionHostId('builder'), projectHostSetupId: 'r1', repoId: 'r1', path: '/repo' }) expect(automation.sourceContext).toMatchObject({ kind: 'task-source', provider: 'github', projectId: 'github:stablyai/orca', hostId: toSshExecutionHostId('builder'), projectHostSetupId: 'r1', repoId: 'r1', providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } }) }) it('marks runtime-owned automations as remote-host scheduled', async () => { const store = await createStore() store.addRepo( makeRepo({ executionHostId: toRuntimeExecutionHostId('gpu-server'), upstream: { owner: 'stablyai', repo: 'orca' } }) ) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) expect(automation.schedulerOwner).toBe('remote_host_service') expect(automation.runContext).toMatchObject({ hostId: toRuntimeExecutionHostId('gpu-server') }) }) it('snapshots automation contexts onto runs', async () => { const store = await createStore() store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } })) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) store.updateAutomation(automation.id, { sourceContext: null, runContext: null }) expect(run.runContext).toEqual(automation.runContext) expect(run.sourceContext).toEqual(automation.sourceContext) expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({ runContext: automation.runContext, sourceContext: automation.sourceContext }) }) it('backfills legacy automation contexts on load', async () => { const store = await createStore() store.addRepo( makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' }, connectionId: 'builder' }) ) const automation = store.createAutomation({ name: 'Legacy nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) const persisted = readDataFile() as { automations: Record[] automationRuns: Record[] } delete persisted.automations[0].runContext delete persisted.automations[0].sourceContext delete persisted.automationRuns[0].runContext delete persisted.automationRuns[0].sourceContext writeDataFile(persisted) const reloaded = await createStore() const migratedAutomation = reloaded .listAutomations() .find((entry) => entry.id === automation.id) const migratedRun = reloaded .listAutomationRuns(automation.id) .find((entry) => entry.id === run.id) expect(migratedAutomation?.runContext).toMatchObject({ kind: 'workspace-run', projectId: 'github:stablyai/orca', hostId: toSshExecutionHostId('builder'), projectHostSetupId: 'r1', repoId: 'r1', path: '/repo' }) expect(migratedAutomation?.sourceContext).toMatchObject({ kind: 'task-source', provider: 'github', projectId: 'github:stablyai/orca', hostId: toSshExecutionHostId('builder'), projectHostSetupId: 'r1', repoId: 'r1', providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } }) expect(migratedRun?.runContext).toEqual(migratedAutomation?.runContext) expect(migratedRun?.sourceContext).toEqual(migratedAutomation?.sourceContext) }) it('shrinks an oversized automationRuns file on load without any later mutation', async () => { const seed = await createStore() seed.addRepo( makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' }, connectionId: 'builder' }) ) const automation = seed.createAutomation({ name: 'Every minute', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) seed.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) seed.flush() // Why: a second load+flush settles the one-shot UI migration flags first, so they (not the prune) don't mark the store dirty. const warm = await createStore() warm.flush() const persisted = readDataFile() as { automationRuns: Record[] } const template = persisted.automationRuns[0] persisted.automationRuns = Array.from({ length: 250 }, (_, i) => { const legacy: Record = { ...template, id: `legacy-run-${i}`, // Only final runs are evictable; the real-world blowup was skipped_precheck rows. status: 'skipped_precheck', createdAt: 1_000 + i, scheduledFor: 1_000 + i } // Legacy files predate runNumber; backfill must run BEFORE the prune so survivors keep their true ordinals. delete legacy.runNumber return legacy }) writeDataFile(persisted) vi.useFakeTimers() try { const reloaded = await createStore() expect(reloaded.listAutomationRuns(automation.id)).toHaveLength(100) // The load-path prune must mark state dirty on its own; nothing else here saves. vi.advanceTimersByTime(1000) await reloaded.waitForPendingWrite() } finally { vi.useRealTimers() } const healed = readDataFile() as { automationRuns: Record[] } expect(healed.automationRuns).toHaveLength(100) expect(healed.automationRuns.at(-1)?.id).toBe('legacy-run-249') // Survivors carry their true lifetime ordinals (151..250), not restarted ones. expect(healed.automationRuns[0]?.runNumber).toBe(151) expect(healed.automationRuns.at(-1)?.runNumber).toBe(250) }) it('does not strand an in-flight run whose completion lands after the retention cap', async () => { const store = await createStore() store.addRepo( makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' }, connectionId: 'builder' }) ) const automation = store.createAutomation({ name: 'Every minute', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'new_per_run', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const base = new Date('2026-05-13T09:00:00Z').getTime() const inFlight = store.createAutomationRun(automation, base) store.updateAutomationRun({ runId: inFlight.id, status: 'dispatched', workspaceId: null, error: null }) // 120 later runs reach a final status while the first one is still dispatched. let firstCompletedId = '' for (let i = 1; i <= 120; i++) { const later = store.createAutomationRun(automation, base + i * 60_000) firstCompletedId ||= later.id store.updateAutomationRun({ runId: later.id, status: 'completed', workspaceId: null, error: null }) } // The late completion must still find its row. const completed = store.updateAutomationRun({ runId: inFlight.id, status: 'completed', workspaceId: null, error: null }) expect(completed.id).toBe(inFlight.id) const runs = store.listAutomationRuns(automation.id) expect(runs.some((run) => run.id === inFlight.id)).toBe(true) // Store can briefly hold cap+2: the last-created run finalizes after its creation-time prune, and the late completion lands without a prune of its own. expect(runs.some((run) => run.id === firstCompletedId)).toBe(false) expect(runs.length).toBeLessThanOrEqual(102) }) it('persists automation precheck config and run results', async () => { const store = await createStore() store.addRepo(makeRepo()) const automation = store.createAutomation({ name: 'Conditional', prompt: 'Run checks', precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) store.updateAutomationRun({ runId: run.id, status: 'skipped_precheck', precheckResult: { command: 'test -f ready', exitCode: 1, timedOut: false, durationMs: 12, stdout: '', stderr: 'missing', stdoutTruncated: false, stderrTruncated: false, error: null, startedAt: 10, completedAt: 22 }, error: 'Precheck exited with code 1.' }) expect(store.listAutomations()[0].precheck).toEqual({ command: 'test -f ready', timeoutSeconds: 30 }) expect(store.listAutomationRuns(automation.id)[0].precheckResult).toMatchObject({ exitCode: 1, stderr: 'missing' }) expect(store.updateAutomation(automation.id, { precheck: null }).precheck).toBeNull() }) it('numbers automation run titles per automation', async () => { const store = await createStore() store.addRepo(makeRepo()) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const first = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) const duplicate = store.createAutomationRun( automation, new Date('2026-05-13T09:00:00Z').getTime() ) const second = store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime()) expect(first.title).toBe('Nightly run 1') expect(duplicate.id).toBe(first.id) expect(duplicate.title).toBe('Nightly run 1') expect(second.title).toBe('Nightly run 2') }) it('records feature interactions when automations are created or manually queued', async () => { const store = await createStore() store.addRepo(makeRepo()) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime(), 'scheduled') store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime(), 'manual') expect(store.getUI().featureInteractions?.['automation-created']?.interactionCount).toBe(1) expect(store.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1) const persisted = readDataFile() as PersistedState expect(persisted.ui?.featureInteractions?.['automation-created']).toMatchObject({ interactionCount: 1 }) expect(persisted.ui?.featureInteractions?.['automation-run']).toMatchObject({ interactionCount: 1 }) }) it('snapshots automation run workspace names for deleted-workspace history', async () => { const store = await createStore() store.addRepo(makeRepo()) store.setWorktreeMeta('wt1', { displayName: 'Nightly workspace' }) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) store.removeWorktreeMeta('wt1') expect(run.workspaceDisplayName).toBe('Nightly workspace') expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe( 'Nightly workspace' ) }) it('backfills automation run workspace names before workspace deletion', async () => { const store = await createStore() store.addRepo(makeRepo()) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) const updatedCount = store.snapshotAutomationRunWorkspaceDisplayName('wt1', 'Deleted workspace') expect(updatedCount).toBe(1) expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe( 'Deleted workspace' ) }) it('persists automation run output snapshots across later status updates', async () => { const store = await createStore() store.addRepo(makeRepo()) const automation = store.createAutomation({ name: 'Nightly', prompt: 'Run checks', agentId: 'claude', projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', timezone: 'UTC', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: new Date('2026-05-13T00:00:00Z').getTime() }) const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' store.updateAutomationRun({ runId: run.id, status: 'dispatched', workspaceId: 'wt1', terminalSessionId: 'tab-1', terminalPaneKey: paneKey, terminalPtyId: 'pty-run', outputSnapshot: { format: 'plain_text', content: 'Run finished', capturedAt: 1, truncated: false }, error: null }) store.updateAutomationRun({ runId: run.id, status: 'completed', workspaceId: 'wt1', usage: null, error: null }) const persisted = store.listAutomationRuns(automation.id)[0] expect(persisted.outputSnapshot).toMatchObject({ content: 'Run finished', truncated: false }) expect(persisted).toMatchObject({ terminalSessionId: 'tab-1', terminalPaneKey: paneKey, terminalPtyId: 'pty-run' }) }) // ── 3. Corrupt JSON → falls back to defaults ──────────────────────── it('falls back to defaults when data file contains invalid JSON', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{invalid json', 'utf-8') const store = await createStore() expect(store.getRepos()).toEqual([]) expect(store.getSettings().theme).toBe('system') expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) // ── 4. Schema migration: merges with defaults ─────────────────────── it('merges loaded data with defaults for missing fields', async () => { writeDataFile({ schemaVersion: 1, repos: [makeRepo()], worktreeMeta: {}, settings: { theme: 'dark' }, githubCache: { pr: {}, issue: {} } // ui and workspaceSession intentionally omitted }) const store = await createStore() // ui should have defaults const ui = store.getUI() expect(ui.sidebarWidth).toBe(280) expect(ui.rightSidebarOpen).toBe(true) expect(ui.rightSidebarTab).toBe('explorer') // settings should preserve the overridden value expect(store.getSettings().theme).toBe('dark') // new fields get defaults when missing from persisted data expect(store.getSettings().editorAutoSave).toBe(false) expect(store.getSettings().editorAutoSaveDelayMs).toBe(1000) expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) expect(store.getSettings().sourceControlViewMode).toBe('list') expect(store.getSettings().showGitIgnoredFiles).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) expect(store.getSettings().showAutomationsButton).toBe(true) expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(false) expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear', 'jira']) expect(store.getSettings().experimentalActivity).toBe(false) expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(store.getSettings().experimentalTerminalAttention).toBe(false) expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) }) it('migrates legacy commit-message AI settings to source-control AI on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { commitMessageAi: { enabled: true, agentId: 'cursor', selectedModelByAgent: { cursor: 'gpt-5.2' }, selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } }, discoveredModelsByAgent: { cursor: [{ id: 'gpt-5.2', label: 'GPT 5.2' }] }, discoveredModelsByAgentByHost: { 'ssh:conn-1': { cursor: [{ id: 'remote-model', label: 'Remote Model' }] } }, selectedThinkingByModel: { 'gpt-5.2': 'high' }, customPrompt: 'Use Conventional Commits.', customAgentCommand: 'cursor-agent' } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() const sourceControlAi = store.getSettings().sourceControlAi expect(sourceControlAi).toMatchObject({ enabled: true, agentId: 'cursor', selectedModelByAgent: { cursor: 'gpt-5.2' }, selectedThinkingByModel: { 'gpt-5.2': 'high' }, customAgentCommand: 'cursor-agent', instructionsByOperation: { commitMessage: 'Use Conventional Commits.', pullRequest: '', branchName: 'Use Conventional Commits.' } }) expect(sourceControlAi?.selectedModelByAgentByHost?.['ssh:conn-1']?.cursor).toBe('remote-model') expect(sourceControlAi?.discoveredModelsByAgent?.cursor?.[0]?.id).toBe('gpt-5.2') expect(sourceControlAi?.discoveredModelsByAgentByHost?.['ssh:conn-1']?.cursor?.[0]?.id).toBe( 'remote-model' ) expect(store.getSettings().commitMessageAi?.customPrompt).toBe('Use Conventional Commits.') }) it('migrates first-work branch auto-rename on for existing profiles once', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { autoRenameBranchFromWork: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().autoRenameBranchFromWork).toBe(true) expect(store.getSettings().autoRenameBranchFromWorkDefaultedOn).toBe(true) }) it('preserves first-work branch auto-rename opt-outs after the default-on migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { autoRenameBranchFromWork: false, autoRenameBranchFromWorkDefaultedOn: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().autoRenameBranchFromWork).toBe(false) expect(store.getSettings().autoRenameBranchFromWorkDefaultedOn).toBe(true) }) it('does not let settings updates clear the first-work branch auto-rename migration guard', async () => { const store = await createStore() const updated = store.updateSettings({ autoRenameBranchFromWorkDefaultedOn: false }) expect(updated.autoRenameBranchFromWorkDefaultedOn).toBe(true) }) it('migrates inherited TUI scroll sensitivity defaults to one report on first load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalTuiScrollSensitivity: 3 }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalTuiScrollSensitivity).toBe(1) expect(store.getSettings().terminalTuiScrollSensitivityDefaultedToOne).toBe(true) store.flush() expect((readDataFile() as PersistedState).settings.terminalTuiScrollSensitivity).toBe(1) }) it('preserves TUI scroll sensitivity choices after the one-report migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalTuiScrollSensitivity: 3, terminalTuiScrollSensitivityDefaultedToOne: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalTuiScrollSensitivity).toBe(3) expect(store.getSettings().terminalTuiScrollSensitivityDefaultedToOne).toBe(true) }) it('stamps the TUI scroll sensitivity migration guard on future updates', async () => { const store = await createStore() const updated = store.updateSettings({ terminalTuiScrollSensitivity: 3, terminalTuiScrollSensitivityDefaultedToOne: false }) expect(updated.terminalTuiScrollSensitivity).toBe(3) expect(updated.terminalTuiScrollSensitivityDefaultedToOne).toBe(true) }) it('merges rollback commit-message AI writes into existing source-control AI on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { sourceControlAi: { enabled: true, agentId: 'codex', selectedModelByAgent: { codex: 'source-model' }, selectedModelByAgentByHost: {}, discoveredModelsByAgent: {}, discoveredModelsByAgentByHost: {}, selectedThinkingByModel: { 'source-model': 'medium' }, customAgentCommand: 'codex', instructionsByOperation: { commitMessage: 'Source commit prompt', pullRequest: 'Preserve PR prompt' }, modelOverridesByOperation: { pullRequest: { selectedModelByAgent: { claude: 'pr-model' }, selectedThinkingByModel: { 'pr-model': 'high' } } }, prCreationDefaults: { draft: true, openAfterCreate: true } }, commitMessageAi: { enabled: false, agentId: 'claude', selectedModelByAgent: { claude: 'legacy-model' }, selectedModelByAgentByHost: { 'ssh:conn-1': { claude: 'remote-legacy-model' } }, discoveredModelsByAgent: {}, discoveredModelsByAgentByHost: {}, selectedThinkingByModel: { 'legacy-model': 'high' }, customPrompt: 'Rollback commit prompt', customAgentCommand: 'claude' } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() const sourceControlAi = store.getSettings().sourceControlAi expect(sourceControlAi).toMatchObject({ enabled: false, agentId: 'claude', selectedModelByAgent: { codex: 'source-model' }, selectedThinkingByModel: { 'source-model': 'medium' }, customAgentCommand: 'claude', instructionsByOperation: { commitMessage: 'Rollback commit prompt', pullRequest: 'Preserve PR prompt', branchName: 'Rollback commit prompt' }, prCreationDefaults: { draft: true, openAfterCreate: true } }) expect(sourceControlAi?.selectedModelByAgentByHost?.['ssh:conn-1']).toBeUndefined() expect(sourceControlAi?.modelOverridesByOperation?.commitMessage).toEqual({ selectedModelByAgent: { claude: 'legacy-model' }, selectedModelByAgentByHost: { 'ssh:conn-1': { claude: 'remote-legacy-model' } }, selectedThinkingByModel: { 'legacy-model': 'high' } }) expect(sourceControlAi?.modelOverridesByOperation?.pullRequest).toEqual({ selectedModelByAgent: { claude: 'pr-model' }, selectedThinkingByModel: { 'pr-model': 'high' } }) expect(store.getSettings().commitMessageAi).toMatchObject({ enabled: false, agentId: 'claude', selectedModelByAgent: { claude: 'legacy-model' }, customPrompt: 'Rollback commit prompt', customAgentCommand: 'claude' }) store.flush() const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) expect(persisted.settings.sourceControlAi.actions.commitMessage).toEqual({ agentId: 'claude', commandInputTemplate: '{basePrompt}\n\nRollback commit prompt' }) expect(persisted.settings.sourceControlAi.actions.branchName).toEqual({ agentId: 'claude', commandInputTemplate: 'Rollback commit prompt\n\n{basePrompt}' }) }) it('does not let rollback projection clobber existing source-control action templates on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { sourceControlAi: { enabled: true, agentId: 'codex', selectedModelByAgent: {}, selectedModelByAgentByHost: {}, discoveredModelsByAgent: {}, discoveredModelsByAgentByHost: {}, selectedThinkingByModel: {}, customAgentCommand: '', instructionsByOperation: { commitMessage: '', pullRequest: '', branchName: '' }, actions: { commitMessage: { agentId: 'codex', commandInputTemplate: 'use $best-commit-msg to write a commit' }, branchName: { agentId: 'claude', commandInputTemplate: 'name this branch from {firstPrompt}' } }, prCreationDefaults: {} }, commitMessageAi: { enabled: true, agentId: 'codex', selectedModelByAgent: {}, selectedModelByAgentByHost: {}, discoveredModelsByAgent: {}, discoveredModelsByAgentByHost: {}, selectedThinkingByModel: {}, customPrompt: 'use $best-commit-msg to write a commit', customAgentCommand: '' } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().sourceControlAi?.actions?.commitMessage).toEqual({ agentId: 'codex', commandInputTemplate: 'use $best-commit-msg to write a commit' }) expect(store.getSettings().sourceControlAi?.actions?.branchName).toEqual({ agentId: 'claude', commandInputTemplate: 'name this branch from {firstPrompt}' }) }) it('keeps a cleared global commit-message recipe template after persistence re-read', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { commitMessageAi: { enabled: true, agentId: 'codex', selectedModelByAgent: {}, selectedModelByAgentByHost: {}, discoveredModelsByAgent: {}, discoveredModelsByAgentByHost: {}, selectedThinkingByModel: {}, customPrompt: '모든 커밋 메시지는 한국어로 작성한다', customAgentCommand: '' } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() const current = store.getSettings().sourceControlAi! store.updateSettings({ sourceControlAi: { ...current, actions: setSourceControlActionDefault(current.actions, 'commitMessage', { commandInputTemplate: '{basePrompt}' }) } }) store.flush() const reopened = await createStore() expect(reopened.getSettings().sourceControlAi?.actions?.commitMessage).toMatchObject({ commandInputTemplate: '{basePrompt}' }) expect(reopened.getSettings().commitMessageAi?.customPrompt).toBe('') }, 10_000) it('normalizes malformed visible task providers on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { visibleTaskProviders: ['gitlab', 'unknown', 'gitlab'] }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab', 'jira']) }) it('preserves a deliberate Jira provider opt-out after migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { visibleTaskProviders: ['gitlab'], visibleTaskProvidersDefaultedForJira: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab']) }) it('normalizes malformed terminal shortcut policy on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalShortcutPolicy: 'terminal-maybe' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first') }) it('normalizes malformed source control group order on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { sourceControlGroupOrder: 'tracked-first' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') }) it('repairs drifted task provider defaults on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { visibleTaskProviders: ['linear'], defaultTaskSource: 'github' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().defaultTaskSource).toBe('github') expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'linear', 'jira']) }) it('normalizes invalid task provider defaults on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { visibleTaskProviders: ['gitlab'], defaultTaskSource: 'bitbucket' as never }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().defaultTaskSource).toBe('gitlab') expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab', 'jira']) }) it('normalizes persisted open-in applications on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { openInApplications: [ { id: 'cursor', label: ' Cursor ', command: ' cursor ' }, { id: 'cursor', label: 'Dup', command: 'dup' }, { id: '', label: 'Zed', command: 'zed' }, { id: 'bad', label: ' ', command: 'bad' } ] }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().openInApplications).toEqual([ { id: 'cursor', label: 'Cursor', command: 'cursor' }, { id: 'open-in-3', label: 'Zed', command: 'zed' } ]) }) it('migrates the legacy floating terminal disabled default to enabled', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalEnabled: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalEnabled).toBe(true) expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true) }) it('preserves a post-migration floating terminal opt-out', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalEnabled: false, floatingTerminalDefaultedForAllUsers: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalEnabled).toBe(false) expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true) }) it('migrates the legacy OSC 52 clipboard disabled default to enabled', async () => { // Why this migration exists: the old off default was persisted for every // profile, so without the one-shot flip #10567 would only fix new installs. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalAllowOsc52Clipboard: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalAllowOsc52Clipboard).toBe(true) expect(store.getSettings().terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) }) it('persists the OSC 52 clipboard migration stamp back to disk', async () => { // Why round-trip a store first: on a bare legacy profile ~30 other migrations also // set loadNeedsSave, so the save happens regardless and this migration's own dirty // flag goes untested. Re-loading a profile the new build already wrote leaves OSC 52 // as the only unmigrated key — which is exactly the upgrade case that matters. // Why no flush(): flush() writes unconditionally, so only the debounced load-path // save proves this migration marked the state dirty by itself. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const migrated = await createStore() migrated.flush() const settled = readDataFile() as { settings: Record } settled.settings.terminalAllowOsc52Clipboard = false delete settled.settings.terminalAllowOsc52ClipboardDefaultedOnForAllUsers writeDataFile(settled) vi.useFakeTimers() try { const store = await createStore() // Why over-advance: the debounce is exactly 1000ms, so an exact-fit advance turns a // future debounce raise into a confusing no-write instead of a loud failure. vi.advanceTimersByTime(5000) await store.waitForPendingWrite() } finally { vi.useRealTimers() } const persisted = readDataFile() as { settings?: { terminalAllowOsc52Clipboard?: boolean terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean } } expect(persisted.settings?.terminalAllowOsc52Clipboard).toBe(true) expect(persisted.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) }) it('preserves a post-migration OSC 52 clipboard opt-out', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalAllowOsc52Clipboard: false, terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalAllowOsc52Clipboard).toBe(false) expect(store.getSettings().terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) }) it('arms the one-shot notice when the OSC 52 flip overrides a persisted off', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalAllowOsc52Clipboard: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().osc52ClipboardDefaultOnNoticePending).toBe(true) }) it('leaves the OSC 52 notice disarmed for a profile with no persisted value', async () => { // Why: the notice explains an overridden choice; a fresh profile made no choice. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().osc52ClipboardDefaultOnNoticePending).toBe(false) }) it('keeps the OSC 52 notice armed on disk until the renderer clears it', async () => { // Why the disk assertion: the flip happens during load, before any window exists, and // once the settings stamp lands the arming predicate is false forever — so the on-disk // ui flag is the only thing that survives a crash before the toast renders. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalAllowOsc52Clipboard: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const armed = await createStore() armed.flush() const armedOnDisk = readDataFile() as { ui?: { osc52ClipboardDefaultOnNoticePending?: boolean } } expect(armedOnDisk.ui?.osc52ClipboardDefaultOnNoticePending).toBe(true) const reloaded = await createStore() expect(reloaded.getUI().osc52ClipboardDefaultOnNoticePending).toBe(true) reloaded.updateUI({ osc52ClipboardDefaultOnNoticePending: false }) reloaded.flush() const cleared = await createStore() // Why re-check after the stamp: a cleared notice must not be resurrected by a later load. expect(cleared.getUI().osc52ClipboardDefaultOnNoticePending).toBe(false) }) it('migrates the legacy Linux primary-selection default to enabled', async () => { await withPlatform('linux', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { primarySelectionMiddleClickPaste: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().primarySelectionMiddleClickPaste).toBe(true) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForLinux).toBe(true) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForTerminalDefaults).toBe( true ) }) }) it('preserves a post-migration Linux primary-selection opt-out', async () => { await withPlatform('linux', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { primarySelectionMiddleClickPaste: false, primarySelectionMiddleClickPasteDefaultedForLinux: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().primarySelectionMiddleClickPaste).toBe(false) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForLinux).toBe(true) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForTerminalDefaults).toBe( true ) }) }) it('migrates the legacy macOS primary-selection default to enabled', async () => { await withPlatform('darwin', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { primarySelectionMiddleClickPaste: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().primarySelectionMiddleClickPaste).toBe(true) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForLinux).toBe(false) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForTerminalDefaults).toBe( true ) }) }) it('preserves a post-migration macOS primary-selection opt-out', async () => { await withPlatform('darwin', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { primarySelectionMiddleClickPaste: false, primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().primarySelectionMiddleClickPaste).toBe(false) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForTerminalDefaults).toBe( true ) }) }) it('keeps the primary-selection default disabled on Windows profiles', async () => { await withPlatform('win32', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { primarySelectionMiddleClickPaste: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().primarySelectionMiddleClickPaste).toBe(false) expect(store.getSettings().primarySelectionMiddleClickPasteDefaultedForTerminalDefaults).toBe( false ) }) }) it('seeds trusted floating workspace directories from legacy explicit cwd values', async () => { const legacyFloatingCwd = join(testState.dir, 'legacy-floating-cwd') mkdirSync(legacyFloatingCwd) const canonicalLegacyFloatingCwd = realpathSync(legacyFloatingCwd) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalCwd: legacyFloatingCwd }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalCwd).toBe(legacyFloatingCwd) expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([canonicalLegacyFloatingCwd]) store.flush() expect( (readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings ?.floatingTerminalTrustedCwds ).toEqual([canonicalLegacyFloatingCwd]) }) it('persists the floating cwd migration marker when a legacy explicit cwd is unavailable', async () => { const unavailableLegacyFloatingCwd = join(testState.dir, 'missing-floating-cwd') writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalCwd: unavailableLegacyFloatingCwd }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalCwd).toBe(unavailableLegacyFloatingCwd) expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([]) store.flush() expect( (readDataFile() as { settings?: { floatingTerminalCwdMigratedToAppWorkspace?: boolean } }) .settings?.floatingTerminalCwdMigratedToAppWorkspace ).toBe(true) }) it('does not seed trusted floating workspace directories after the cwd migration has run', async () => { const postMigrationCwd = join(testState.dir, 'post-migration-cwd') mkdirSync(postMigrationCwd) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalCwd: postMigrationCwd, floatingTerminalCwdMigratedToAppWorkspace: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalCwd).toBe(postMigrationCwd) expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([]) }) it('restores migrated blank floating terminal cwd settings to home shorthand', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalCwd: '', floatingTerminalCwdMigratedToAppWorkspace: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalCwd).toBe('~') }) it('preserves legacy home shorthand as the floating terminal cwd', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalCwd: '~' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalCwd).toBe('~') expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([]) }) it('canonicalizes persisted floating workspace trust paths on load', async () => { const trustedTarget = join(testState.dir, 'trusted-target') const trustedLink = join(testState.dir, 'trusted-link') mkdirSync(trustedTarget) symlinkDirectorySync(trustedTarget, trustedLink) const canonicalTrustedTarget = realpathSync(trustedTarget) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalTrustedCwds: [trustedLink] }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([canonicalTrustedTarget]) store.flush() expect( (readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings ?.floatingTerminalTrustedCwds ).toEqual([canonicalTrustedTarget]) }) it('preserves temporarily unavailable floating workspace trust paths on load', async () => { const unavailableTrustedPath = join(testState.dir, 'offline-drive', 'notes') writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalTrustedCwds: [unavailableTrustedPath] }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([unavailableTrustedPath]) store.flush() expect( (readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings ?.floatingTerminalTrustedCwds ).toEqual([unavailableTrustedPath]) }) it('drops blank floating workspace trust paths on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { floatingTerminalTrustedCwds: ['', ' '] }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([]) store.flush() expect( (readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings ?.floatingTerminalTrustedCwds ).toEqual([]) }) it('preserves custom notification sound paths from persisted settings', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { notifications: { customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().notifications).toMatchObject({ enabled: true, agentTaskComplete: true, terminalBell: false, suppressWhenFocused: true, customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg', customSoundVolume: 100 }) }) it('clamps notification custom sound volume from persisted settings', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { notifications: { customSoundVolume: 250 } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().notifications.customSoundVolume).toBe(100) }) it('defaults invalid notification custom sound volume from persisted settings', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { notifications: { customSoundVolume: Number.NaN } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().notifications.customSoundVolume).toBe(100) }) it('preserves editorAutoSaveDelayMs when set in persisted data', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { editorAutoSaveDelayMs: 2500 }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().editorAutoSaveDelayMs).toBe(2500) }) it('preserves editorAutoSave when set to true in persisted data', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { editorAutoSave: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().editorAutoSave).toBe(true) }) it('keeps legacy rightSidebarOpenByDefault readable from persisted data', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { rightSidebarOpenByDefault: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) }) it('preserves terminalUseSeparateLightTheme when persisted as false', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalUseSeparateLightTheme: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false) }) it('round-trips selected terminal theme names across reload', async () => { const store = await createStore() store.updateSettings({ terminalThemeDark: 'One Light', terminalThemeLight: 'GitHub Light' }) store.flush() const persisted = readDataFile() as PersistedState expect(persisted.settings.terminalThemeDark).toBe('One Light') expect(persisted.settings.terminalThemeLight).toBe('GitHub Light') const reopened = await createStore() expect(reopened.getSettings().terminalThemeDark).toBe('One Light') expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light') }) // ── 5. addRepo and getRepo ────────────────────────────────────────── it('addRepo stores a repo retrievable by getRepo', async () => { const store = await createStore() store.addRepo(makeRepo()) const fetched = store.getRepo('r1') expect(fetched).toBeDefined() expect(fetched!.displayName).toBe('test') // No username has been resolved yet — hydration must not probe git/gh. expect(fetched!.gitUsername).toBe('') }) it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => { const store = await createStore() store.addRepo(makeRepo()) expect(store.getRepo('r1')!.gitUsername).toBe('') expect(store.setResolvedRepoGitUsername('r1', 'testuser')).toBe(true) expect(store.getRepo('r1')!.gitUsername).toBe('testuser') // Unchanged value reports no change so callers can skip renderer notify. expect(store.setResolvedRepoGitUsername('r1', 'testuser')).toBe(false) expect(store.setResolvedRepoGitUsername('missing', 'x')).toBe(false) store.flush() const persisted = readDataFile() as PersistedState expect(persisted.repos[0].gitUsername).toBe('testuser') }) it('deleteProjectGroup ungroups repos from the deleted group subtree', async () => { const store = await createStore() const root = store.createProjectGroup({ name: 'Platform', createdFrom: 'folder-scan' }) const child = store.createProjectGroup({ name: 'Services', parentGroupId: root.id, createdFrom: 'folder-scan' }) const sibling = store.createProjectGroup({ name: 'Tools', createdFrom: 'manual' }) store.addRepo(makeRepo({ id: 'direct', path: '/direct', projectGroupId: root.id })) store.addRepo(makeRepo({ id: 'nested', path: '/nested', projectGroupId: child.id })) store.addRepo(makeRepo({ id: 'sibling', path: '/sibling', projectGroupId: sibling.id })) expect(store.deleteProjectGroup(root.id)).toBe(true) expect(store.getProjectGroups().map((group) => group.id)).toEqual([sibling.id]) expect(store.getRepo('direct')?.projectGroupId).toBeNull() expect(store.getRepo('nested')?.projectGroupId).toBeNull() expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id) }) it('adapts flat folder-scan groups into sparse nested folder scopes on load', async () => { writeDataFile({ schemaVersion: 1, repos: [ makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: 'root' }), makeRepo({ id: 'web', path: '/workspace/platform/web', projectGroupId: 'root' }), makeRepo({ id: 'repo1', path: '/workspace/platform/packages/shared/repo1', projectGroupId: 'root' }), makeRepo({ id: 'repo2', path: '/workspace/platform/packages/shared/repo2', projectGroupId: 'root' }) ], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, projectGroups: [ { id: 'root', name: 'Platform', parentPath: '/workspace/platform', parentGroupId: null, createdFrom: 'folder-scan', tabOrder: 0, isCollapsed: false, color: null, createdAt: 1, updatedAt: 1 } ] }) const store = await createStore() const groups = store.getProjectGroups() const shared = groups.find((group) => group.name === 'packages/shared') expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ ['Platform', null, '/workspace/platform'], ['packages/shared', 'root', '/workspace/platform/packages/shared'] ]) expect(store.getRepo('api')?.projectGroupId).toBe('root') expect(store.getRepo('web')?.projectGroupId).toBe('root') expect(store.getRepo('repo1')?.projectGroupId).toBe(shared?.id) expect(store.getRepo('repo2')?.projectGroupId).toBe(shared?.id) }) it('creates a project group when persisted group history is very large', async () => { const projectGroups: ProjectGroup[] = Array.from({ length: 130_000 }, (_, index) => ({ id: `group-${index}`, name: `Group ${index}`, parentPath: null, parentGroupId: null, createdFrom: 'manual', tabOrder: index, isCollapsed: false, color: null, createdAt: index, updatedAt: index })) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, projectGroups }) const store = await createStore() const group = store.createProjectGroup({ name: 'New group', createdFrom: 'manual' }) expect(group.tabOrder).toBe(projectGroups.length) }) it('sanitizes invalid project group updates before persisting a repo', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Platform', createdFrom: 'manual' }) store.addRepo(makeRepo({ id: 'r1', projectGroupId: group.id, projectGroupOrder: 1 })) const updated = store.updateRepo('r1', { projectGroupId: '', projectGroupOrder: Number.POSITIVE_INFINITY } as never) expect(updated?.projectGroupId).toBeNull() expect(updated?.projectGroupOrder).toBe(1) }) it('updates repo execution host identity', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) const updated = store.updateRepo('r1', { executionHostId: 'runtime:env-1' }) expect(updated?.executionHostId).toBe('runtime:env-1') expect(store.getRepo('r1')?.executionHostId).toBe('runtime:env-1') }) it('getRepo returns undefined for nonexistent id', async () => { const store = await createStore() expect(store.getRepo('nonexistent')).toBeUndefined() }) // ── 6. removeProject cleans up worktree meta ────────────────────────── it('removeProject deletes the repo and its worktree meta', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) store.setWorktreeMeta('r1::/path/wt2', { displayName: 'wt2' }) store.setWorktreeMeta('r2::/other', { displayName: 'other' }) store.removeProject('r1') expect(store.getRepo('r1')).toBeUndefined() expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined() expect(store.getWorktreeMeta('r1::/path/wt2')).toBeUndefined() expect(store.getWorktreeMeta('r2::/other')).toBeDefined() expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) it('does not retain topology authority for historically removed repos', async () => { const store = await createStore() for (let index = 0; index < 25; index += 1) { const repoId = `removed-${index}` store.addRepo(makeRepo({ id: repoId, path: `/repo-${index}` })) store.setWorkspaceSession({ ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [repoId]: 1 } }) store.removeProject(repoId) } expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId).toEqual({}) }) it('removeProject prunes the repo worktrees from workspace session state', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) store.setWorktreeMeta('r2::/other', { displayName: 'other' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'r1::/path/wt1': 111, 'r2::/other': 222 } }) store.removeProject('r1') const session = store.getWorkspaceSession() expect(session.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() expect(session.lastVisitedAtByWorktreeId?.['r2::/other']).toBe(222) }) it('removeProject prunes the repo worktrees from per-host workspace session partitions', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) const hostId = 'ssh:host-a' store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'r1::/path/wt1': 333 } }, hostId ) store.removeProject('r1') const hostSession = store.getWorkspaceSession(hostId) expect(hostSession.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined() }) it('lists only persisted workspace-session host partitions', async () => { const store = await createStore() expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) store.getWorkspaceSession('ssh:not-persisted') store.setWorkspaceSession(getDefaultWorkspaceSession(), 'ssh:ssh-a') store.setWorkspaceSession(getDefaultWorkspaceSession(), 'runtime:environment-a') expect(store.getWorkspaceSessionHostIds()).toEqual([ 'local', 'ssh:ssh-a', 'runtime:environment-a' ]) }) it('removeProject removes the derived project host setup compatibility record', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) store.removeProject('r1') expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r2']) expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual(['r2']) }) it('removeProject deletes child and parent lineage for the repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) store.setWorktreeLineage( 'r1::/path/child', makeWorktreeLineage({ worktreeId: 'r1::/path/child', parentWorktreeId: 'r1::/path/parent' }) ) store.setWorktreeLineage( 'r2::/other-child', makeWorktreeLineage({ worktreeId: 'r2::/other-child', parentWorktreeId: 'r1::/path/parent' }) ) store.setWorktreeLineage( 'r2::/other', makeWorktreeLineage({ worktreeId: 'r2::/other', parentWorktreeId: 'r2::/parent' }) ) store.removeProject('r1') expect(store.getWorktreeLineage('r1::/path/child')).toBeUndefined() expect(store.getWorktreeLineage('r2::/other-child')).toBeUndefined() expect(store.getWorktreeLineage('r2::/other')).toBeDefined() }) // ── 6b. removeProjectForHost is host-scoped ─────────────────────────── it('removeProjectForHost removes only the target host row for a shared repo id', async () => { const store = await createStore() // Same repo id on both local and an SSH host. store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) store.addRepo( makeRepo({ id: 'shared', path: '/remote/repo', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' }) ) store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt', hostId: 'local' }) store.setWorktreeMeta('shared::/remote/repo/wt', { displayName: 'remote-wt', hostId: 'ssh:ssh-old' }) store.removeProjectForHost('shared', 'ssh:ssh-old') const remaining = store.getRepos().filter((r) => r.id === 'shared') expect(remaining).toHaveLength(1) expect(remaining[0].path).toBe('/local/repo') // Local worktree meta survives; the SSH host's meta is pruned. expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined() expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() }) it('removeProjectForHost keeps the surviving host session for a shared repo id + path', async () => { const store = await createStore() // Same repo id AND same path on both local and an SSH host, so the owner key // `shared::/repo` is identical across hosts. The host-scoped prune must only // touch the removed host's session partition. store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) store.addRepo( makeRepo({ id: 'shared', path: '/repo', connectionId: 'ssh-a', executionHostId: 'ssh:ssh-a' }) ) store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'shared::/repo': 111 } }) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'shared::/repo': 222 } }, 'ssh:ssh-a' ) store.removeProjectForHost('shared', 'ssh:ssh-a') // The removed SSH host's session is pruned; the surviving local session stays. expect( store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo'] ).toBeUndefined() expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111) }) it('removeProjectForHost on the local host keeps a surviving SSH host session', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) store.addRepo( makeRepo({ id: 'shared', path: '/repo', connectionId: 'ssh-a', executionHostId: 'ssh:ssh-a' }) ) store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'shared::/repo': 111 } }) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'shared::/repo': 222 } }, 'ssh:ssh-a' ) store.removeProjectForHost('shared', 'local') expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined() expect( store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo'] ).toBe(222) }) it('removeProjectForHost prunes only the removed host when a third host also shares the owner key', async () => { const store = await createStore() // Same repo id + path on local and two SSH hosts, so the owner key // `shared::/repo` is identical across all three. Removing one non-local host // must prune only that host's partition and leave both the local session and // the other surviving SSH host intact. store.addRepo(makeRepo({ id: 'shared', path: '/repo' })) store.addRepo( makeRepo({ id: 'shared', path: '/repo', connectionId: 'ssh-a', executionHostId: 'ssh:ssh-a' }) ) store.addRepo( makeRepo({ id: 'shared', path: '/repo', connectionId: 'ssh-b', executionHostId: 'ssh:ssh-b' }) ) store.setWorktreeMeta('shared::/repo', { displayName: 'local', hostId: 'local' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'shared::/repo': 111 } }) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'shared::/repo': 222 } }, 'ssh:ssh-a' ) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { 'shared::/repo': 333 } }, 'ssh:ssh-b' ) store.removeProjectForHost('shared', 'ssh:ssh-a') // Only the removed host's partition is pruned; local and the other SSH host survive. expect( store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo'] ).toBeUndefined() expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111) expect( store.getWorkspaceSession('ssh:ssh-b').lastVisitedAtByWorktreeId?.['shared::/repo'] ).toBe(333) }) it('reorderReposForHost independently reorders local and SSH rows with shared ids', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'shared', path: '/local/shared' })) store.addRepo( makeRepo({ id: 'shared', path: '/ssh/shared', connectionId: 'target' }) ) store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' })) store.addRepo( makeRepo({ id: 'ssh-two', path: '/ssh/two', connectionId: 'target' }) ) expect(store.reorderReposForHost(['local-two', 'shared'], 'local')).toBe(true) expect(store.getRepos().map((repo) => repo.path)).toEqual([ '/local/two', '/ssh/shared', '/local/shared', '/ssh/two' ]) expect(store.reorderReposForHost(['ssh-two', 'shared'], 'ssh:target')).toBe(true) expect(store.getRepos().map((repo) => repo.path)).toEqual([ '/local/two', '/ssh/two', '/local/shared', '/ssh/shared' ]) }) it('reorderReposForHost rejects stale or duplicate host permutations without mutation', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'local-one', path: '/local/one' })) store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' })) store.addRepo( makeRepo({ id: 'ssh-one', path: '/ssh/one', connectionId: 'target', executionHostId: 'ssh:target' }) ) const originalPaths = store.getRepos().map((repo) => repo.path) expect(store.reorderReposForHost(['local-two'], 'local')).toBe(false) expect(store.reorderReposForHost(['local-one', 'local-one'], 'local')).toBe(false) expect(store.reorderReposForHost(['missing', 'local-two'], 'local')).toBe(false) expect(store.getRepos().map((repo) => repo.path)).toEqual(originalPaths) }) it('removeProjectForHost prunes the SSH host meta (tagged hostId) for a shared id', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) store.addRepo( makeRepo({ id: 'shared', path: '/remote/repo', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' }) ) // SSH worktree meta correctly carries hostId (the normal, stamped case). store.setWorktreeMeta('shared::/remote/repo/wt', { displayName: 'remote-wt', hostId: 'ssh:ssh-old' }) // A hostId-less meta is treated as local and left behind (never delete the wrong host's meta). store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt' }) store.removeProjectForHost('shared', 'ssh:ssh-old') expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined() }) it('removeProjectForHost prunes everything when the id exists on no other host', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'only', connectionId: 'ssh-x', executionHostId: 'ssh:ssh-x' })) store.setWorktreeMeta('only::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-x' }) store.removeProjectForHost('only', 'ssh:ssh-x') expect(store.getRepo('only')).toBeUndefined() expect(store.getWorktreeMeta('only::/repo/wt')).toBeUndefined() }) // ── 6c. reassignSshTargetId re-adopts orphaned workspaces ───────────── it('reassignSshTargetId re-points repos and worktree metas onto the new id', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) store.setWorktreeMeta('r1::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') expect(repoIds).toEqual(['r1']) const repo = store.getRepo('r1')! expect(repo.connectionId).toBe('ssh-new') expect(repo.executionHostId).toBe('ssh:ssh-new') expect(store.getWorktreeMeta('r1::/repo/wt')!.hostId).toBe('ssh:ssh-new') }) it('reassignSshTargetId leaves repos on other hosts untouched', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'local-repo', path: '/local' })) store.addRepo( makeRepo({ id: 'ssh-repo', path: '/remote', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' }) ) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') expect(repoIds).toEqual(['ssh-repo']) expect(store.getRepo('local-repo')!.connectionId).toBeUndefined() expect(store.getRepo('ssh-repo')!.connectionId).toBe('ssh-new') }) it('reassignSshTargetId re-points a repo that only carries connectionId (no executionHostId)', async () => { const store = await createStore() // SSH repos created via addRemoteRepoFromPath leave executionHostId unset. store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' })) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') expect(repoIds).toEqual(['r1']) const repo = store.getRepo('r1')! expect(repo.connectionId).toBe('ssh-new') // Must not stamp an executionHostId where there wasn't one. expect(repo.executionHostId).toBeUndefined() }) it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => { const store = await createStore() // A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only. store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') expect(repoIds).toEqual([]) // no repo matched store.flush() const reloaded = await createStore() expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new') }) it('reassignSshTargetId migrates session pty ids, reconnect list, leases, and host scope', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'r1::/wt', activeTabId: 'tab1', tabsByWorktree: { 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-2' })] }, terminalLayoutsByTabId: {}, remoteSessionIdsByTabId: { tab1: 'ssh:ssh-old@@pty-2' }, activeConnectionIdsAtShutdown: ['ssh-old'] }) store.upsertSshRemotePtyLease({ targetId: 'ssh-old', ptyId: 'pty-2', state: 'detached' }) store.updateUI({ workspaceHostScope: 'ssh:ssh-old', visibleWorkspaceHostIds: ['local', 'ssh:ssh-old'], workspaceHostOrder: ['ssh:ssh-old', 'local'] }) store.reassignSshTargetId('ssh-old', 'ssh-new') store.flush() const reloaded = await createStore() const session = reloaded.getWorkspaceSession() expect(session.tabsByWorktree['r1::/wt'][0].ptyId).toBe('ssh:ssh-new@@pty-2') expect(session.remoteSessionIdsByTabId).toEqual({ tab1: 'ssh:ssh-new@@pty-2' }) expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-new']) expect(reloaded.getSshRemotePtyLeases('ssh-new')).toHaveLength(1) expect(reloaded.getSshRemotePtyLeases('ssh-old')).toHaveLength(0) const ui = reloaded.getUI() expect(ui.workspaceHostScope).toBe('ssh:ssh-new') expect(ui.visibleWorkspaceHostIds).toEqual(['local', 'ssh:ssh-new']) expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local']) }) it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { const store = await createStore() store.setWorkspaceSession( { activeRepoId: null, activeWorktreeId: null, activeTabId: null, tabsByWorktree: { 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-9' })] }, terminalLayoutsByTabId: {} }, 'ssh:ssh-old' ) store.reassignSshTargetId('ssh-old', 'ssh-new') store.flush() const reloaded = await createStore() // Old-key partition is gone; the re-keyed one carries migrated pty ids. expect(reloaded.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) expect(reloaded.getWorkspaceSession('ssh:ssh-new').tabsByWorktree['r1::/wt'][0].ptyId).toBe( 'ssh:ssh-new@@pty-9' ) }) it('reassignSshTargetId keeps the live partition when both host keys exist', async () => { const store = await createStore() const baseSession = { activeRepoId: null, activeWorktreeId: null, activeTabId: null, terminalLayoutsByTabId: {} } store.setWorkspaceSession( { ...baseSession, tabsByWorktree: { 'r1::/dead': [] } }, 'ssh:ssh-old' ) store.setWorkspaceSession( { ...baseSession, tabsByWorktree: { 'r1::/live': [] } }, 'ssh:ssh-new' ) store.reassignSshTargetId('ssh-old', 'ssh-new') expect(store.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) expect(store.getWorkspaceSession('ssh:ssh-new').tabsByWorktree).toEqual({ 'r1::/live': [] }) }) it('reassignSshTargetId re-points an independent provisioned host setup', async () => { const store = await createStore() store.addRepo({ ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), upstream: { owner: 'stablyai', repo: 'cloud-project' } }) store.createProjectHostSetup({ projectId: 'github:stablyai/cloud-project', hostId: 'ssh:ssh-old', setupId: 'cloud-project::ssh-old', setupMethod: 'provisioned' }) // Meta-only re-adoption must still migrate the provisioned setup, or new worktrees would be born on a dead host id. store.reassignSshTargetId('ssh-old', 'ssh-new') const setups = store.getProjectHostSetups() const provisioned = setups.find((entry) => entry.id === 'cloud-project::ssh-old') expect(provisioned?.hostId).toBe('ssh:ssh-new') }) it('reassignSshTargetId drops a stale setup when the new host already has one', async () => { const store = await createStore() store.addRepo({ ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), upstream: { owner: 'stablyai', repo: 'cloud-project' } }) store.createProjectHostSetup({ projectId: 'github:stablyai/cloud-project', hostId: 'ssh:ssh-old', setupId: 'setup-old', setupMethod: 'provisioned' }) store.createProjectHostSetup({ projectId: 'github:stablyai/cloud-project', hostId: 'ssh:ssh-new', setupId: 'setup-new', setupMethod: 'provisioned' }) store.reassignSshTargetId('ssh-old', 'ssh-new') const setups = store.getProjectHostSetups() expect(setups.find((entry) => entry.id === 'setup-old')).toBeUndefined() expect(setups.find((entry) => entry.id === 'setup-new')?.hostId).toBe('ssh:ssh-new') }) // ── 7. updateRepo ────────────────────────────────────────────────── it('updateRepo modifies the repo in place', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { displayName: 'renamed' }) expect(updated).not.toBeNull() expect(updated!.displayName).toBe('renamed') expect(store.getRepo('r1')!.displayName).toBe('renamed') }) it('updateRepo targets one host when repo ids collide', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) store.addRepo( makeRepo({ id: 'shared', path: '/remote/repo', connectionId: 'server', executionHostId: 'ssh:server' }) ) const updated = store.updateRepo('shared', { displayName: 'Remote renamed' }, 'ssh:server') expect(updated?.path).toBe('/remote/repo') expect(store.getRepos().filter((repo) => repo.id === 'shared')).toMatchObject([ { path: '/local/repo', displayName: 'test' }, { path: '/remote/repo', displayName: 'Remote renamed' } ]) }) it('updateRepo keeps project host setup compatibility records in sync', async () => { const store = await createStore() store.addRepo(makeRepo({ worktreeBasePath: '../worktrees' })) store.updateRepo('r1', { displayName: 'renamed', worktreeBasePath: '../new-worktrees', upstream: { owner: 'stablyai', repo: 'orca' } }) expect(store.getProjects()).toEqual([ expect.objectContaining({ id: 'github:stablyai/orca', displayName: 'renamed', sourceRepoIds: ['r1'] }) ]) expect(store.getProjectHostSetups()).toEqual([ expect.objectContaining({ id: 'r1', projectId: 'github:stablyai/orca', displayName: 'renamed', worktreeBasePath: '../new-worktrees' }) ]) }) it('repo mutations preserve independent project host setup records', async () => { const independentProject = makeProject({ id: 'cloud-project', displayName: 'Cloud Project' }) const independentSetup = makeProjectHostSetup({ id: 'cloud-project::gpu-vm', projectId: independentProject.id, hostId: 'runtime:gpu-vm', repoId: '', path: '/srv/cloud-project', displayName: 'GPU VM' }) writeDataFile({ ...getDefaultPersistedState(testState.dir), repos: [makeRepo({ id: 'r1' })], projects: [independentProject], projectHostSetups: [independentSetup] }) const store = await createStore() store.updateRepo('r1', { displayName: 'renamed' }) store.reorderRepos(['r1']) expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) expect(store.getProjectHostSetups()).toEqual([ expect.objectContaining({ id: 'r1', displayName: 'renamed' }), independentSetup ]) }) it('updates independent project host setup records directly', async () => { const independentProject = makeProject({ id: 'cloud-project', displayName: 'Cloud Project' }) const independentSetup = makeProjectHostSetup({ id: 'cloud-project::gpu-vm', projectId: independentProject.id, hostId: 'runtime:gpu-vm', repoId: '', path: '/srv/cloud-project', displayName: 'GPU VM' }) writeDataFile({ ...getDefaultPersistedState(testState.dir), projects: [independentProject], projectHostSetups: [independentSetup] }) const store = await createStore() const result = store.updateProjectHostSetup({ setupId: independentSetup.id, updates: { displayName: 'GPU VM renamed', path: '/srv/renamed', worktreeBasePath: '../worktrees', setupState: 'ready', setupMethod: 'cloned', gitUsername: 'alice' } }) expect(result).toEqual({ project: independentProject, setup: expect.objectContaining({ id: independentSetup.id, displayName: 'GPU VM renamed', path: '/srv/renamed', worktreeBasePath: '../worktrees', setupState: 'ready', setupMethod: 'cloned', gitUsername: 'alice' }) }) expect(store.getProjectHostSetups()[0]).toMatchObject({ displayName: 'GPU VM renamed', path: '/srv/renamed' }) }) it('creates independent project host setup records for provisioning flows', async () => { const store = await createStore() store.addRepo({ ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), upstream: { owner: 'stablyai', repo: 'cloud-project' } }) const result = store.createProjectHostSetup({ projectId: 'github:stablyai/cloud-project', hostId: 'runtime:gpu-vm', setupId: 'cloud-project::gpu-vm', displayName: 'GPU VM', setupState: 'setting-up', setupMethod: 'provisioned' }) expect(result?.project).toMatchObject({ id: 'github:stablyai/cloud-project', displayName: 'Cloud Project' }) expect(result?.setup).toMatchObject({ id: 'cloud-project::gpu-vm', projectId: 'github:stablyai/cloud-project', hostId: 'runtime:gpu-vm', repoId: '', path: '', displayName: 'GPU VM', setupState: 'setting-up', setupMethod: 'provisioned' }) expect(store.getRepos()).toHaveLength(1) expect(store.getProjectHostSetups()).toEqual([ expect.objectContaining({ id: 'r1', repoId: 'r1' }), result?.setup ]) }) it('rejects duplicate project host setup creation for the same host', async () => { const store = await createStore() store.addRepo({ ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), upstream: { owner: 'stablyai', repo: 'cloud-project' } }) const independentSetup = makeProjectHostSetup({ id: 'cloud-project::gpu-vm', projectId: 'github:stablyai/cloud-project', hostId: 'runtime:gpu-vm' }) store.createProjectHostSetup({ projectId: independentSetup.projectId, hostId: independentSetup.hostId, setupId: independentSetup.id }) expect(() => store.createProjectHostSetup({ projectId: 'github:stablyai/cloud-project', hostId: 'runtime:gpu-vm', setupId: 'duplicate' }) ).toThrow('Project host setup already exists: cloud-project::gpu-vm') }) it('updates repo-backed project host setup metadata through the repo record', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', displayName: 'Repo', worktreeBasePath: '../old' })) const result = store.updateProjectHostSetup({ setupId: 'r1', updates: { displayName: 'Repo renamed', worktreeBasePath: '../new', setupMethod: 'cloned' } }) expect(result?.repo).toMatchObject({ id: 'r1', displayName: 'Repo renamed', worktreeBasePath: '../new', projectHostSetupMethod: 'cloned' }) expect(result?.project).toMatchObject({ id: 'repo:r1', displayName: 'Repo renamed' }) expect(result?.setup).toMatchObject({ id: 'r1', displayName: 'Repo renamed', worktreeBasePath: '../new', setupMethod: 'cloned' }) }) it('rejects repo-backed project host setup path changes', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) expect(() => store.updateProjectHostSetup({ setupId: 'r1', updates: { path: '/other' } }) ).toThrow('Repo-backed project host setup paths must be changed by re-importing the project.') }) it('deletes independent project host setup records without deleting the project', async () => { const independentProject = makeProject({ id: 'cloud-project', displayName: 'Cloud Project' }) const independentSetup = makeProjectHostSetup({ id: 'cloud-project::gpu-vm', projectId: independentProject.id, hostId: 'runtime:gpu-vm', repoId: '', path: '/srv/cloud-project', displayName: 'GPU VM' }) writeDataFile({ ...getDefaultPersistedState(testState.dir), projects: [independentProject], projectHostSetups: [independentSetup] }) const store = await createStore() const result = store.deleteProjectHostSetup({ setupId: independentSetup.id }) expect(result).toEqual({ project: independentProject, setup: independentSetup }) expect(store.getProjects()).toEqual([independentProject]) expect(store.getProjectHostSetups()).toEqual([]) }) it('deletes repo-backed project host setups by removing the compatibility repo', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) store.setWorktreeMeta('r1::/path/wt1', { displayName: 'wt1' }) const result = store.deleteProjectHostSetup({ setupId: 'r1' }) expect(result?.project).toMatchObject({ id: 'repo:r1' }) expect(result?.setup).toMatchObject({ id: 'r1', repoId: 'r1' }) expect(result?.repo).toMatchObject({ id: 'r1' }) expect(store.getRepo('r1')).toBeUndefined() expect(store.getProjects()).toEqual([]) expect(store.getProjectHostSetups()).toEqual([]) expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined() }) it('updateRepo preserves repo-backed project host setup method', async () => { const store = await createStore() store.addRepo(makeRepo()) store.updateRepo('r1', { projectHostSetupMethod: 'cloned' }) expect(store.getRepo('r1')?.projectHostSetupMethod).toBe('cloned') expect(store.getProjectHostSetups()).toEqual([ expect.objectContaining({ id: 'r1', setupMethod: 'cloned' }) ]) }) it('updateRepo drops repo icons that fail shared sanitization', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { repoIcon: { type: 'image', source: 'upload', src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' } as never }) expect(updated).not.toBeNull() expect(updated!.repoIcon).toBeUndefined() expect(store.getRepo('r1')!.repoIcon).toBeUndefined() }) it('updateRepo normalizes custom repo badge colors before storing', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { badgeColor: ' ABCDEF ' }) expect(updated!.badgeColor).toBe('#abcdef') expect(store.getRepo('r1')!.badgeColor).toBe('#abcdef') }) it('updateRepo ignores invalid repo badge colors without clearing the existing color', async () => { const store = await createStore() store.addRepo(makeRepo({ badgeColor: '#123456' })) const updated = store.updateRepo('r1', { badgeColor: 'blue' }) expect(updated!.badgeColor).toBe('#123456') expect(store.getRepo('r1')!.badgeColor).toBe('#123456') }) it('getRepo does not expose invalid persisted repo icons', async () => { const store = await createStore() store.addRepo( makeRepo({ repoIcon: { type: 'image', source: 'upload', src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' } as never }) ) expect(store.getRepo('r1')!.repoIcon).toBeUndefined() expect(store.getRepos()[0]!.repoIcon).toBeUndefined() }) it('updateRepo normalizes and persists repo upstream metadata', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { upstream: { owner: ' stablyai ', repo: ' orca ' } }) expect(updated!.upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) store.updateRepo('r1', { upstream: null }) store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.upstream).toBeNull() }) it('updateRepo persists the resolved no-usable-remote identity marker', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { gitRemoteIdentity: { canonicalKey: 'gitlab.example.com/team/orca', remoteName: 'origin', remoteUrl: 'git@gitlab.example.com:team/orca.git' } }) expect(updated!.gitRemoteIdentity).toEqual({ canonicalKey: 'gitlab.example.com/team/orca', remoteName: 'origin', remoteUrl: 'git@gitlab.example.com:team/orca.git' }) store.updateRepo('r1', { gitRemoteIdentity: null }) store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.gitRemoteIdentity).toBeNull() }) it('getRepo does not expose invalid persisted repo upstream metadata', async () => { const store = await createStore() store.addRepo(makeRepo({ upstream: { owner: '', repo: 42 } as never })) expect(store.getRepo('r1')!.upstream).toBeUndefined() expect(store.getRepos()[0]!.upstream).toBeUndefined() }) it('updateRepo returns null for nonexistent id', async () => { const store = await createStore() expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull() }) it('updateRepo persists issueSourcePreference across reloads', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { issueSourcePreference: 'upstream' }) expect(updated!.issueSourcePreference).toBe('upstream') store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.issueSourcePreference).toBe('upstream') }) it('updateRepo persists fork sync mode across reloads', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { forkSyncMode: 'safe-auto' }) expect(updated!.forkSyncMode).toBe('safe-auto') store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.forkSyncMode).toBe('safe-auto') }) it('updateRepo ignores invalid fork sync mode updates', async () => { const store = await createStore() store.addRepo(makeRepo({ forkSyncMode: 'ask' })) const updated = store.updateRepo('r1', { forkSyncMode: 'always' as never }) expect(updated!.forkSyncMode).toBe('ask') store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.forkSyncMode).toBe('ask') }) it('getRepo does not expose invalid persisted fork sync mode values', async () => { writeDataFile({ ...getDefaultPersistedState(testState.dir), repos: [makeRepo({ forkSyncMode: 'always' as never })] }) const store = await createStore() expect(store.getRepo('r1')!.forkSyncMode).toBeUndefined() expect(store.getRepos()[0]!.forkSyncMode).toBeUndefined() }) it('updateRepo with issueSourcePreference=undefined clears the preference', async () => { const store = await createStore() store.addRepo(makeRepo({ issueSourcePreference: 'origin' })) expect(store.getRepo('r1')!.issueSourcePreference).toBe('origin') // Why: `Object.assign` skips undefined, so updateRepo needs an explicit delete branch or the key with value undefined wouldn't clear. store.updateRepo('r1', { issueSourcePreference: undefined }) expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined() store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.issueSourcePreference).toBeUndefined() }) it('updateRepo stamps legacy external-worktree visibility before changing old repos', async () => { const store = await createStore() store.addRepo( makeRepo({ addedAt: Date.UTC(2026, 4, 24), externalWorktreeVisibility: undefined, externalWorktreeVisibilityLegacy: undefined }) ) const updated = store.updateRepo('r1', { externalWorktreeVisibility: 'hide' }) expect(updated!.externalWorktreeVisibility).toBe('hide') expect(updated!.externalWorktreeVisibilityLegacy).toBe(true) }) it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => { const store = await createStore() store.addRepo( makeRepo({ issueSourcePreference: 'origin', sourceControlAi: { instructionsByOperation: { commitMessage: 'Repo style' }, prCreationDefaults: { draft: true } } }) ) store.updateRepo('r1', { issueSourcePreference: undefined, sourceControlAi: undefined }) expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined() expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined() store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.issueSourcePreference).toBeUndefined() expect(reloaded.getRepo('r1')!.sourceControlAi).toBeUndefined() }) it('updateRepo treats source-control AI null as a transport clear sentinel', async () => { const store = await createStore() store.addRepo( makeRepo({ sourceControlAi: { enabled: true, customAgentCommand: 'repo-agent {prompt}' } }) ) store.updateRepo('r1', { sourceControlAi: null }) expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined() store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.sourceControlAi).toBeUndefined() }) it('updateRepo normalizes source-control AI overrides before storing', async () => { const store = await createStore() store.addRepo(makeRepo()) const updated = store.updateRepo('r1', { sourceControlAi: { instructionsByOperation: { commitMessage: 'Repo style', pullRequest: 42, unknown: 'ignored' }, prCreationDefaults: { draft: true, useTemplate: null, openAfterCreate: 'yes' }, modelOverridesByOperation: { commitMessage: { selectedModelByAgent: { codex: 'gpt-5.4', claude: false }, selectedThinkingByModel: { 'gpt-5.4': 'high', bad: true } }, unknown: { selectedModelByAgent: { codex: 'ignored' } } } } as never }) expect(updated!.sourceControlAi).toEqual({ instructionsByOperation: { commitMessage: 'Repo style' }, actionOverrides: { commitMessage: { commandInputTemplate: '{basePrompt}\n\nRepo style' } }, prCreationDefaults: { draft: true, useTemplate: null }, modelOverridesByOperation: { commitMessage: { selectedModelByAgent: { codex: 'gpt-5.4' }, selectedThinkingByModel: { 'gpt-5.4': 'high' } } } }) }) it('updateRepo ignores malformed source-control AI overrides without clearing existing overrides', async () => { const store = await createStore() store.addRepo( makeRepo({ sourceControlAi: { instructionsByOperation: { commitMessage: 'Keep me' } } }) ) const updated = store.updateRepo('r1', { sourceControlAi: 'bad' as never }) expect(updated!.sourceControlAi).toEqual({ instructionsByOperation: { commitMessage: 'Keep me' }, actionOverrides: { commitMessage: { commandInputTemplate: '{basePrompt}\n\nKeep me' } } }) }) // ── 8. setWorktreeMeta and getWorktreeMeta ───────────────────────── it('setWorktreeMeta creates meta with defaults for missing fields', async () => { const store = await createStore() const meta = store.setWorktreeMeta('wt1', { displayName: 'my-wt' }) expect(meta.displayName).toBe('my-wt') expect(meta.comment).toBe('') expect(meta.linkedIssue).toBeNull() expect(meta.isArchived).toBe(false) expect(typeof meta.sortOrder).toBe('number') }) it('setWorktreeMeta merges with existing meta', async () => { const store = await createStore() store.setWorktreeMeta('wt1', { displayName: 'first', comment: 'hello' }) const updated = store.setWorktreeMeta('wt1', { comment: 'updated' }) expect(updated.displayName).toBe('first') expect(updated.comment).toBe('updated') }) it('persists paired Jira linked-item metadata and drops mismatched source context', async () => { const store = await createStore() const linkedWorkItem = { provider: 'jira' as const, type: 'issue' as const, number: 0, title: 'ORCA-123 Link Jira', url: 'https://company.atlassian.net/browse/ORCA-123', jiraIdentifier: 'ORCA-123' } const linkedTaskSourceContext = { kind: 'task-source' as const, provider: 'jira' as const, projectId: 'project-1', hostId: 'runtime:env-1' as const, providerIdentity: { provider: 'jira' as const, siteId: 'site-1', siteUrl: 'https://company.atlassian.net', projectKey: 'ORCA' }, accountLabel: 'ada@example.com' } store.setWorktreeMeta('wt-jira', { linkedWorkItem, linkedTaskSourceContext }) store.flush() const restored = await createStore() expect(restored.getWorktreeMeta('wt-jira')).toMatchObject({ linkedWorkItem, linkedTaskSourceContext }) expect( restored.setWorktreeMeta('wt-jira', { linkedTaskSourceContext: { ...linkedTaskSourceContext, provider: 'linear' } }).linkedTaskSourceContext ).toBeNull() expect( restored.setWorktreeMeta('wt-jira', { linkedTaskSourceContext: { ...linkedTaskSourceContext, providerIdentity: { ...linkedTaskSourceContext.providerIdentity, projectKey: 'OTHER' } } }).linkedTaskSourceContext ).toBeNull() }) it('discards malformed persisted task-source metadata without aborting store load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: { 'wt-malformed': { linkedWorkItem: { provider: 'jira', type: 'issue', number: 0, title: 'ORCA-123 Link Jira', url: 'https://company.atlassian.net/browse/ORCA-123', jiraIdentifier: 'ORCA-123' }, linkedTaskSourceContext: { kind: 'task-source', provider: 'jira', projectId: 'project-1', hostId: 'local', accountLabel: 44, providerIdentity: { provider: 'jira', siteId: 'site-1', siteUrl: 'https://company.atlassian.net', projectKey: 'ORCA' } } } } }) const store = await createStore() expect(store.getWorktreeMeta('wt-malformed')?.linkedWorkItem?.jiraIdentifier).toBe('ORCA-123') expect(store.getWorktreeMeta('wt-malformed')?.linkedTaskSourceContext).toBeNull() }) it('drops corrupt worktreeMeta entries while still normalizing valid siblings', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeLineageById: { 'r1::/tmp/wt': { parentWorktreeId: 'wt-sibling' } }, workspaceLineageByChildKey: { 'worktree:r1::/tmp/wt': { parentWorkspaceKey: 'worktree:wt-sibling' } }, worktreeMeta: { 'r1::/tmp/wt': null, 'r1::/tmp/scalar': 5, 'wt-sibling': { linkedWorkItem: { provider: 'jira', type: 'issue', number: 0, title: 'ORCA-123 Link Jira', url: 'https://company.atlassian.net/browse/ORCA-123', jiraIdentifier: 'ORCA-123' }, linkedTaskSourceContext: { kind: 'task-source', provider: 'jira', projectId: 'project-1', hostId: 'local', accountLabel: 44, providerIdentity: { provider: 'jira', siteId: 'site-1', siteUrl: 'https://company.atlassian.net', projectKey: 'ORCA' } } } } }) const store = await createStore() expect(store.getWorktreeMeta('wt-sibling')?.linkedWorkItem?.jiraIdentifier).toBe('ORCA-123') expect(store.getWorktreeMeta('wt-sibling')?.linkedTaskSourceContext).toBeNull() // Corrupt entries must not survive: gcStaleWorktreeMeta keeps timestamp-less keys, and downstream // consumers deref worktreeMeta values unguarded (also keeps a rollback to an older build loadable). expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/wt') expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/scalar') expect(store.getWorktreeLineage('r1::/tmp/wt')).toBeUndefined() store.flush() const persisted = readDataFile() as PersistedState expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/wt') expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/scalar') expect(persisted.workspaceLineageByChildKey).not.toHaveProperty('worktree:r1::/tmp/wt') }) it('creates and updates folder workspaces from folder-backed project groups', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Platform', parentPath: '/workspace/platform', createdFrom: 'folder-scan' }) const linkedTask = { provider: 'linear' as const, type: 'issue' as const, number: 0, title: 'Refund fix', url: 'https://linear.app/acme/issue/ENG-123', linearIdentifier: 'ENG-123' } const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix', linkedTask }) const updated = store.updateFolderWorkspace(workspace.id, { comment: 'Coordinate api and web', isPinned: true, lastActivityAt: 123 }) expect(workspace.folderPath).toBe('/workspace/platform') expect(updated).toMatchObject({ id: workspace.id, projectGroupId: group.id, name: 'Refund fix', folderPath: '/workspace/platform', linkedTask, comment: 'Coordinate api and web', isPinned: true, lastActivityAt: 123 }) expect(store.getFolderWorkspaces()).toHaveLength(1) }) it('round-trips Jira item and source context for repo-less folder workspaces', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Platform', parentPath: '/workspace/platform', createdFrom: 'folder-scan' }) const linkedTask = { provider: 'jira' as const, type: 'issue' as const, number: 0, title: 'ORCA-123 Link Jira', url: 'https://company.atlassian.net/browse/ORCA-123', jiraIdentifier: 'ORCA-123' } const linkedTaskSourceContext = { kind: 'task-source' as const, provider: 'jira' as const, projectId: group.id, hostId: 'runtime:folder-env' as const, repoId: null, providerIdentity: { provider: 'jira' as const, siteId: 'site-1', siteUrl: 'https://company.atlassian.net', projectKey: 'ORCA' }, accountLabel: 'ada@example.com' } const workspace = store.createFolderWorkspace({ projectGroupId: group.id, linkedTask, linkedTaskSourceContext }) store.flush() const restored = await createStore() expect(restored.getFolderWorkspaces()).toContainEqual( expect.objectContaining({ id: workspace.id, linkedTask, linkedTaskSourceContext: expect.objectContaining(linkedTaskSourceContext) }) ) }) it('rejects folder workspace creation for non-folder-backed project groups', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Manual', createdFrom: 'manual' }) expect(() => store.createFolderWorkspace({ projectGroupId: group.id })).toThrow( 'Folder-backed project group not found.' ) }) it('normalizes persisted folder workspaces and drops orphaned records', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, projectGroups: [ { id: 'root', name: 'Platform', parentPath: '/workspace/platform', parentGroupId: null, createdFrom: 'folder-scan', tabOrder: 0, isCollapsed: false, color: null, createdAt: 1, updatedAt: 1 } ], folderWorkspaces: [ { id: 'fw-1', projectGroupId: 'root', name: ' ', folderPath: '', comment: 42, isArchived: true, isUnread: true, isPinned: false, sortOrder: 10, lastActivityAt: 5, createdAt: 2, updatedAt: 3 }, { id: 'orphan', projectGroupId: 'missing', name: 'Orphan', folderPath: '/missing' } ] }) const store = await createStore() expect(store.getFolderWorkspaces()).toEqual([ expect.objectContaining({ id: 'fw-1', projectGroupId: 'root', name: 'Untitled workspace', folderPath: '/workspace/platform', comment: '', isArchived: true, isUnread: true }) ]) }) it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => { writeDataFile({ schemaVersion: 1, repos: [ makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: 'root', connectionId: 'ssh-1' }) ], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, projectGroups: [ { id: 'root', name: 'Platform', parentPath: '/workspace/platform', parentGroupId: null, createdFrom: 'folder-scan', tabOrder: 0, isCollapsed: false, color: null, createdAt: 1, updatedAt: 1 } ], folderWorkspaces: [ { id: 'fw-1', projectGroupId: 'root', name: 'Refund fix', folderPath: '/workspace/platform', comment: '', isArchived: false, isUnread: false, isPinned: false, sortOrder: 1, lastActivityAt: 1, createdAt: 1, updatedAt: 1 } ] }) const store = await createStore() expect(store.getProjectGroups()[0]).toMatchObject({ id: 'root', connectionId: 'ssh-1' }) expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' }) }) it('backfills folder-scope SSH provenance from grouped repos despite unrelated same-path SSH repos', async () => { writeDataFile({ schemaVersion: 1, repos: [ makeRepo({ id: 'api-ssh-1', path: '/workspace/platform/api', projectGroupId: 'root', connectionId: 'ssh-1' }), makeRepo({ id: 'api-ssh-2', path: '/workspace/platform/api', projectGroupId: 'other-root', connectionId: 'ssh-2' }) ], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, projectGroups: [ { id: 'root', name: 'Platform', parentPath: '/workspace/platform', parentGroupId: null, createdFrom: 'folder-scan', tabOrder: 0, isCollapsed: false, color: null, createdAt: 1, updatedAt: 1 }, { id: 'other-root', name: 'Platform other', parentPath: '/workspace/platform', parentGroupId: null, createdFrom: 'folder-scan', tabOrder: 1, isCollapsed: false, color: null, createdAt: 1, updatedAt: 1 } ], folderWorkspaces: [ { id: 'fw-1', projectGroupId: 'root', name: 'Refund fix', folderPath: '/workspace/platform', comment: '', isArchived: false, isUnread: false, isPinned: false, sortOrder: 1, lastActivityAt: 1, createdAt: 1, updatedAt: 1 } ] }) const store = await createStore() expect(store.getProjectGroups().find((group) => group.id === 'root')).toMatchObject({ connectionId: 'ssh-1' }) expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' }) }) it('removes folder workspace metadata and its scoped session state only', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Platform', parentPath: '/workspace/platform', createdFrom: 'folder-scan' }) store.addRepo( makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: group.id }) ) const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix' }) const key = folderWorkspaceKey(workspace.id) const tab = makeTerminalTab({ id: 'folder-tab', worktreeId: key }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), activeWorkspaceKey: key, activeWorktreeId: key, activeTabId: tab.id, tabsByWorktree: { [key]: [tab], 'repo::/wt': [makeTerminalTab({ id: 'repo-tab' })] }, terminalLayoutsByTabId: { [tab.id]: { root: null, activeLeafId: null, expandedLeafId: null }, 'repo-tab': { root: null, activeLeafId: null, expandedLeafId: null } }, browserTabsByWorktree: { [key]: [ { id: 'browser-workspace', worktreeId: key, url: 'about:blank', title: 'Blank', loading: false, faviconUrl: null, canGoBack: false, canGoForward: false, loadError: null, createdAt: 1 } ] }, browserPagesByWorkspace: { 'browser-workspace': [ { id: 'page-1', workspaceId: 'browser-workspace', worktreeId: key, url: 'about:blank', title: 'Blank', loading: false, faviconUrl: null, canGoBack: false, canGoForward: false, loadError: null, createdAt: 1 } ] }, activeTabIdByWorktree: { [key]: tab.id }, lastVisitedAtByWorktreeId: { [key]: 10 } }) expect(store.removeFolderWorkspace(workspace.id)).toBe(true) const session = store.getWorkspaceSession() expect(store.getFolderWorkspaces()).toEqual([]) expect(store.getProjectGroups()).toHaveLength(1) expect(store.getRepo('api')?.projectGroupId).toBe(group.id) expect(session.activeWorkspaceKey).toBeNull() expect(session.activeWorktreeId).toBeNull() expect(session.activeTabId).toBeNull() expect(session.tabsByWorktree[key]).toBeUndefined() expect(session.tabsByWorktree['repo::/wt']).toHaveLength(1) expect(session.terminalLayoutsByTabId['folder-tab']).toBeUndefined() expect(session.terminalLayoutsByTabId['repo-tab']).toBeDefined() expect(session.browserPagesByWorkspace?.['browser-workspace']).toBeUndefined() }) // ── 9. Settings: get/update ──────────────────────────────────────── it('updateSettings merges partial updates', async () => { const store = await createStore() const initial = store.getSettings() expect(initial.theme).toBe('system') const updated = store.updateSettings({ theme: 'dark', editorAutoSave: true, editorAutoSaveDelayMs: 1500, appFontFamily: 'Inter', terminalFontSize: 16, terminalFontWeight: 600 }) expect(updated.theme).toBe('dark') expect(updated.editorAutoSave).toBe(true) expect(updated.editorAutoSaveDelayMs).toBe(1500) expect(updated.appFontFamily).toBe('Inter') expect(updated.terminalFontSize).toBe(16) expect(updated.terminalFontWeight).toBe(600) // Other fields preserved expect(updated.branchPrefix).toBe('git-username') }) it('normalizes bot-author overrides on load and every settings write', async () => { writeDataFile({ settings: { prBotAuthorOverrides: [' GretelFlux ', 'gretelflux', 42, '', 'another-bot'] } }) const store = await createStore() expect(store.getSettings().prBotAuthorOverrides).toEqual(['another-bot', 'gretelflux']) const oversized = Array.from( { length: 600 }, (_, index) => ` bot-${String(index).padStart(4, '0')} ` ) const updated = store.updateSettings({ prBotAuthorOverrides: oversized }) expect(updated.prBotAuthorOverrides).toHaveLength(500) expect(updated.prBotAuthorOverrides[0]).toBe('bot-0000') expect(updated.prBotAuthorOverrides[499]).toBe('bot-0499') }) it('normalizes custom mobile pairing addresses on load and every settings write', async () => { writeDataFile({ settings: { mobilePairingCustomAddress: 'host:99999', mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768'] } }) const store = await createStore() expect(store.getSettings().mobilePairingCustomAddress).toBeNull() expect(store.getSettings().mobilePairingCustomAddresses).toEqual(['first.example:6768']) store.flush() expect( (readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress ).toBeNull() const updated = store.updateSettings({ mobilePairingCustomAddress: ' 100.126.117.25:6768 ' }) expect(updated.mobilePairingCustomAddress).toBe('100.126.117.25:6768') expect(updated.mobilePairingCustomAddresses).toEqual([ 'first.example:6768', '100.126.117.25:6768' ]) store.flush() expect( (readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress ).toBe('100.126.117.25:6768') expect( store.updateSettings({ mobilePairingCustomAddress: 42 as never }).mobilePairingCustomAddress ).toBeNull() expect( store.updateSettings({ mobilePairingCustomAddresses: [' second.example ', 'host:99999', 'second.example'] }).mobilePairingCustomAddresses ).toEqual(['second.example']) expect( store.updateSettings({ mobilePairingCustomAddress: 'active.example:6768', mobilePairingCustomAddresses: ['second.example'] }).mobilePairingCustomAddresses ).toEqual(['second.example', 'active.example:6768']) expect( store.updateSettings({ mobilePairingCustomAddresses: ['third.example'] }).mobilePairingCustomAddresses ).toEqual(['third.example', 'active.example:6768']) }) it('notifies settings listeners with changed keys only', async () => { const store = await createStore() const listener = vi.fn() store.onSettingsChanged(listener) store.updateSettings( { theme: 'dark', disabledTuiAgents: ['codex', 'not-real', 'codex'] as never }, { notifyListeners: true, originWebContentsId: 42 } ) expect(listener).toHaveBeenCalledWith( { theme: 'dark', disabledTuiAgents: ['codex'] }, expect.objectContaining({ theme: 'dark', disabledTuiAgents: ['codex'] }), 42 ) }) it('does not notify settings listeners for unchanged scalar updates', async () => { const store = await createStore() const listener = vi.fn() store.onSettingsChanged(listener) store.updateSettings({ theme: store.getSettings().theme }, { notifyListeners: true }) expect(listener).not.toHaveBeenCalled() }) it('does not notify settings listeners unless requested by the producer', async () => { const store = await createStore() const listener = vi.fn() store.onSettingsChanged(listener) store.updateSettings({ theme: 'dark' }) expect(listener).not.toHaveBeenCalled() }) it('migrates missing terminal scrollback rows to the row default and writes back rows only', async () => { writeDataFile({ settings: {} }) const store = await createStore() expect(store.getSettings().terminalScrollbackRows).toBe(5_000) store.flush() const persisted = readDataFile() as { settings?: Record } expect(persisted.settings?.terminalScrollbackRows).toBe(5_000) expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) it('migrates legacy terminal scrollback byte presets by intent', async () => { writeDataFile({ settings: { terminalScrollbackBytes: 25_000_000 } }) const store = await createStore() expect(store.getSettings().terminalScrollbackRows).toBe(10_000) store.flush() const persisted = readDataFile() as { settings?: Record } expect(persisted.settings?.terminalScrollbackRows).toBe(10_000) expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) it('lets persisted terminal scrollback rows win over legacy bytes', async () => { writeDataFile({ settings: { terminalScrollbackRows: 25_000, terminalScrollbackBytes: 100_000_000 } }) const store = await createStore() expect(store.getSettings().terminalScrollbackRows).toBe(25_000) store.flush() const persisted = readDataFile() as { settings?: Record } expect(persisted.settings?.terminalScrollbackRows).toBe(25_000) expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) it('normalizes invalid and clamped terminal scrollback rows on load', async () => { writeDataFile({ settings: { terminalScrollbackRows: '50000' } }) const invalidStore = await createStore() expect(invalidStore.getSettings().terminalScrollbackRows).toBe(5_000) invalidStore.flush() writeDataFile({ settings: { terminalScrollbackRows: 75_000 } }) const clampedStore = await createStore() expect(clampedStore.getSettings().terminalScrollbackRows).toBe(50_000) }) it('normalizes terminal scrollback row updates and ignores stale byte updates', async () => { const store = await createStore() const listener = vi.fn() store.onSettingsChanged(listener) const updated = store.updateSettings( { terminalScrollbackRows: 75_000, terminalScrollbackBytes: 250_000_000 } as never, { notifyListeners: true } ) expect(updated.terminalScrollbackRows).toBe(50_000) expect(listener).toHaveBeenCalledWith( { terminalScrollbackRows: 50_000 }, expect.objectContaining({ terminalScrollbackRows: 50_000 }), undefined ) store.updateSettings({ terminalScrollbackBytes: 10_000_000 } as never) store.flush() const persisted = readDataFile() as { settings?: Record } expect(persisted.settings?.terminalScrollbackRows).toBe(50_000) expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) it('normalizes disabled TUI agents on load and update', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), JSON.stringify({ settings: { disabledTuiAgents: ['codex', 'not-real', 'codex', 'claude'] } }) ) const store = await createStore() expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude', 'claude-agent-teams']) const updated = store.updateSettings({ disabledTuiAgents: ['gemini', 'not-real', 'gemini', 'opencode'] as never }) expect(updated.disabledTuiAgents).toEqual(['gemini', 'opencode']) }) it('enables Claude Agent Teams by default for fresh installs', async () => { const store = await createStore() expect(store.getSettings().disabledTuiAgents).toEqual([]) expect(store.getSettings().claudeAgentTeamsDefaultDisabledMigrated).toBe(true) }) it('migrates yolo default args onto untouched agent launch settings', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), JSON.stringify({ settings: { agentCmdOverrides: {} } }) ) const store = await createStore() expect(store.getSettings().agentDefaultArgs).toMatchObject({ claude: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox', cursor: '--yolo' }) expect(store.getSettings().agentDefaultEnv).toMatchObject({ goose: { GOOSE_MODE: 'auto' } }) expect(store.getSettings().agentYoloDefaultsMigrated).toBe(true) }) it('does not add yolo defaults for legacy agents with command overrides', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), JSON.stringify({ settings: { agentCmdOverrides: { codex: 'codex --profile work', goose: 'goose' } } }) ) const store = await createStore() expect(store.getSettings().agentDefaultArgs?.codex).toBe('') expect(store.getSettings().agentDefaultEnv?.goose).toEqual({}) expect(store.getSettings().agentDefaultArgs?.claude).toBe('--dangerously-skip-permissions') }) it('removes unsupported TUI skip-permissions args from migrated profiles', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), JSON.stringify({ settings: { agentYoloDefaultsMigrated: true, agentDefaultArgs: { opencode: '--dangerously-skip-permissions --model opencode/gpt-5', kilo: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox' } } }) ) const store = await createStore() store.flush() expect(store.getSettings().agentDefaultArgs?.opencode).toBe('--model opencode/gpt-5') expect(store.getSettings().agentDefaultArgs?.kilo).toBe('') expect(store.getSettings().agentDefaultArgs?.codex).toBe( '--dangerously-bypass-approvals-and-sandbox' ) expect((readDataFile() as PersistedState).settings.agentDefaultArgs?.opencode).toBe( '--model opencode/gpt-5' ) expect((readDataFile() as PersistedState).settings.agentDefaultArgs?.kilo).toBe('') }) it('normalizes app icon on load and update', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), JSON.stringify({ settings: { appIcon: 'not-real' } }) ) const store = await createStore() expect(store.getSettings().appIcon).toBe('classic') expect(store.updateSettings({ appIcon: 'watercolor' }).appIcon).toBe('watercolor') expect(store.updateSettings({ appIcon: 'blue' }).appIcon).toBe('blue') expect(store.updateSettings({ appIcon: 'not-real' as never }).appIcon).toBe('classic') }) it('updateSettings keeps the legacy commit-message AI projection in sync', async () => { const store = await createStore() const current = store.getSettings().sourceControlAi! const updated = store.updateSettings({ sourceControlAi: { ...current, enabled: true, agentId: 'codex', selectedModelByAgent: { codex: 'gpt-5.4' }, selectedThinkingByModel: { 'gpt-5.4': 'high' }, instructionsByOperation: { commitMessage: 'Write concise commit messages.', pullRequest: 'Write release-note-ready PR details.' }, customAgentCommand: '' } }) expect(updated.commitMessageAi).toMatchObject({ enabled: true, agentId: 'codex', selectedModelByAgent: { codex: 'gpt-5.4' }, selectedThinkingByModel: { 'gpt-5.4': 'high' }, customPrompt: 'Write concise commit messages.', customAgentCommand: '' }) }) it('updateSettings keeps source-control AI in sync for legacy commit-message updates', async () => { const store = await createStore() const current = store.getSettings().commitMessageAi! const updated = store.updateSettings({ commitMessageAi: { ...current, enabled: false, agentId: 'claude', selectedModelByAgent: { claude: 'sonnet' }, selectedThinkingByModel: { sonnet: 'medium' }, customPrompt: 'Legacy settings update', customAgentCommand: 'claude' } }) expect(updated.sourceControlAi).toMatchObject({ enabled: false, agentId: 'claude', selectedModelByAgent: {}, selectedThinkingByModel: {}, customAgentCommand: 'claude', instructionsByOperation: { commitMessage: 'Legacy settings update', branchName: 'Legacy settings update' } }) expect(updated.sourceControlAi?.modelOverridesByOperation?.commitMessage).toEqual({ selectedModelByAgent: { claude: 'sonnet' }, selectedThinkingByModel: { sonnet: 'medium' } }) }) it('updateSettings normalizes open-in applications', async () => { const store = await createStore() const updated = store.updateSettings({ openInApplications: [ { id: 'cursor', label: ' Cursor ', command: ' cursor ' }, { id: 'cursor', label: 'Dup', command: 'dup' }, { id: 'bad', label: '', command: 'bad' } ] }) expect(updated.openInApplications).toEqual([ { id: 'cursor', label: 'Cursor', command: 'cursor' } ]) }) it('updateSettings deep-merges and clamps notification custom sound volume', async () => { const store = await createStore() const updated = store.updateSettings({ notifications: { ...store.getSettings().notifications, customSoundVolume: -20 } }) expect(updated.notifications.customSoundVolume).toBe(0) expect(updated.notifications.enabled).toBe(true) expect(updated.notifications.customSoundPath).toBeNull() }) it('updateSettings toggles editorAutoSave', async () => { const store = await createStore() expect(store.getSettings().editorAutoSave).toBe(false) store.updateSettings({ editorAutoSave: true }) expect(store.getSettings().editorAutoSave).toBe(true) store.updateSettings({ editorAutoSave: false }) expect(store.getSettings().editorAutoSave).toBe(false) }) it('keeps legacy rightSidebarOpenByDefault writable for backward compatibility', async () => { const store = await createStore() expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) store.updateSettings({ rightSidebarOpenByDefault: false }) expect(store.getSettings().rightSidebarOpenByDefault).toBe(false) store.updateSettings({ rightSidebarOpenByDefault: true }) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) }) it('updateSettings persists sourceControlViewMode as a user setting', async () => { const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') store.updateSettings({ sourceControlViewMode: 'tree' }) expect(store.getSettings().sourceControlViewMode).toBe('tree') }) it('updateSettings persists sourceControlGroupOrder as a user setting', async () => { const store = await createStore() expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') store.updateSettings({ sourceControlGroupOrder: 'staged-first' }) expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first') store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never }) expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') }) it('updateSettings normalizes terminal shortcut policy', async () => { const store = await createStore() store.updateSettings({ terminalShortcutPolicy: 'terminal-first' }) expect(store.getSettings().terminalShortcutPolicy).toBe('terminal-first') store.updateSettings({ terminalShortcutPolicy: 'terminal-maybe' as never }) expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first') }) it('reloads sourceControlViewMode from global settings without touching workspace state', async () => { const workspaceSession = { activeRepoId: 'r1', activeWorktreeId: 'repo1::/worktree-a', activeTabId: 'tab1', tabsByWorktree: { 'repo1::/worktree-a': [ makeTerminalTab({ id: 'tab1', worktreeId: 'repo1::/worktree-a' }) ], 'repo1::/worktree-b': [ makeTerminalTab({ id: 'tab2', worktreeId: 'repo1::/worktree-b' }) ] }, terminalLayoutsByTabId: {}, openFilesByWorktree: {}, markdownFrontmatterVisible: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, activeBrowserTabIdByWorktree: {}, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, browserUrlHistory: [], defaultTerminalTabsAppliedByWorktreeId: {} } writeDataFile({ schemaVersion: 1, repos: [makeRepo()], worktreeMeta: { 'repo1::/worktree-a': { status: 'active' }, 'repo1::/worktree-b': { status: 'active' } }, settings: { theme: 'dark' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession }) const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' }) store.flush() const persisted = readDataFile() as { settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string } workspaceSession?: typeof workspaceSession worktreeMeta?: Record } expect(persisted.settings?.sourceControlViewMode).toBe('tree') expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first') expect(persisted.workspaceSession).toEqual({ ...getDefaultWorkspaceSession(), ...workspaceSession }) expect(persisted.worktreeMeta).toEqual({ 'repo1::/worktree-a': { status: 'active' }, 'repo1::/worktree-b': { status: 'active' } }) expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([ 'settings.sourceControlViewMode' ]) expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([ 'settings.sourceControlGroupOrder' ]) const reloaded = await createStore() expect(reloaded.getSettings().sourceControlViewMode).toBe('tree') expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first') expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a') }) // ── 10. flush writes synchronously ───────────────────────────────── it('flush writes state to disk synchronously', async () => { const store = await createStore() store.addRepo(makeRepo()) store.flush() const persisted = readDataFile() as { repos: Repo[] } expect(persisted.repos).toHaveLength(1) expect(persisted.repos[0].id).toBe('r1') }) it('flush remains safe when a debounced save is also pending', async () => { vi.useFakeTimers() try { const store = await createStore() store.addRepo(makeRepo()) store.flush() vi.advanceTimersByTime(1000) const persisted = readDataFile() as { repos: Repo[] } expect(persisted.repos).toHaveLength(1) expect(persisted.repos[0].id).toBe('r1') } finally { vi.useRealTimers() } }) // ── 11. Debounced save ───────────────────────────────────────────── it('debounced save writes data after the delay', async () => { vi.useFakeTimers() try { const store = await createStore() store.addRepo(makeRepo()) // Before the debounce fires, file should not exist yet (or be stale) vi.advanceTimersByTime(100) // The 1s debounce hasn't elapsed yet vi.advanceTimersByTime(1000) // The timer fired; wait for the async disk write to complete await store.waitForPendingWrite() const persisted = readDataFile() as { repos: Repo[] } expect(persisted.repos).toHaveLength(1) expect(persisted.repos[0].id).toBe('r1') } finally { vi.useRealTimers() } }) // ── Content-hash write skipping ──────────────────────────────────── // Why inode comparison: every real write is a tmp+rename (new inode), so an unchanged inode proves no write happened. it('skips the disk write when a mutation burst nets out to already-persisted state', async () => { vi.useFakeTimers() try { const store = await createStore() store.updateUI({ sidebarWidth: 400 }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const inoBefore = statSync(dataFile()).ino store.updateUI({ sidebarWidth: 500 }) store.updateUI({ sidebarWidth: 400 }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() expect(statSync(dataFile()).ino).toBe(inoBefore) } finally { vi.useRealTimers() } }) it('skips the sync flush when state already matches the last write', async () => { vi.useFakeTimers() try { const store = await createStore() store.updateUI({ sidebarWidth: 420 }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const inoBefore = statSync(dataFile()).ino store.flush() expect(statSync(dataFile()).ino).toBe(inoBefore) } finally { vi.useRealTimers() } }) it('bounds save postponement under sustained mutation bursts (max-wait)', async () => { vi.useFakeTimers() try { const store = await createStore() // Mutations every 500ms reset the 1s debounce; the 5s max-wait must force a write anyway. let width = 400 for (let i = 0; i < 11; i++) { store.updateUI({ sidebarWidth: width++ }) vi.advanceTimersByTime(500) } await store.waitForPendingWrite() expect(existsSync(dataFile())).toBe(true) const persisted = readDataFile() as { ui: { sidebarWidth: number } } expect(persisted.ui.sidebarWidth).toBeGreaterThanOrEqual(400) } finally { vi.useRealTimers() } }) it('re-binding an already-persisted pty does not rewrite the state file', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: null, activeLeafId: null, expandedLeafId: null } } }) const binding = { worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, ptyId: 'daemon-pty' } store.persistPtyBinding(binding) const inoBefore = statSync(dataFile()).ino // Warm-restart re-bind storm: an identical binding re-asserted with a sync flush must not rewrite. store.persistPtyBinding(binding) expect(statSync(dataFile()).ino).toBe(inoBefore) }) // ── worktreeMeta startup GC ──────────────────────────────────────── it('garbage-collects stale local worktreeMeta at load with a 30-day grace', async () => { const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 const RECENT = Date.now() - 1 * 24 * 60 * 60 * 1000 const missing = (name: string): string => join(testState.dir, 'gone', name) const meta = (lastActivityAt: number, extra: Record = {}) => ({ displayName: '', comment: '', lastActivityAt, ...extra }) const liveKey = `r1::${testState.dir}` const deadKey = `r1::${missing('dead')}` const recentKey = `r1::${missing('recent')}` const sshKey = `ssh-repo::/home/alice/gone` const remoteHostKey = `r1::${missing('remote-host')}` const orphanKey = `removed-repo::${missing('orphan')}` const wslKey = `r1::\\\\wsl$\\Ubuntu\\home\\gone` writeDataFile({ repos: [ makeRepo(), makeRepo({ id: 'ssh-repo', path: '/home/alice/repo', connectionId: 'conn-1' }) ], worktreeMeta: { [liveKey]: meta(OLD), [deadKey]: meta(OLD), [recentKey]: meta(RECENT), [sshKey]: meta(OLD), [remoteHostKey]: meta(OLD, { hostId: 'ssh:conn-1' }), [orphanKey]: meta(OLD), [wslKey]: meta(OLD) }, worktreeLineageById: { [deadKey]: { parentWorktreeId: liveKey } } }) const store = await createStore() const kept = Object.keys(store.getAllWorktreeMeta()) expect(kept).toContain(liveKey) // path exists expect(kept).toContain(recentKey) // inside the grace window expect(kept).toContain(sshKey) // SSH repo: remote paths never checked locally expect(kept).toContain(remoteHostKey) // remote hostId on the meta itself expect(kept).toContain(wslKey) // WSL UNC path expect(kept).not.toContain(deadKey) expect(kept).not.toContain(orphanKey) expect(store.getWorktreeLineage(deadKey)).toBeUndefined() }) it('never GCs folder-workspace instance metas — the meta IS the workspace', async () => { const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 const folderInstanceKey = `r1::${join(testState.dir, 'gone-folder')}::workspace:11111111-1111-4111-8111-111111111111` writeDataFile({ repos: [makeRepo({ kind: 'folder' })], worktreeMeta: { [folderInstanceKey]: { displayName: 'Session A', comment: '', lastActivityAt: OLD } } }) const store = await createStore() expect(Object.keys(store.getAllWorktreeMeta())).toContain(folderInstanceKey) }) it('never GCs Linux-style WSL worktree paths on Windows', async () => { const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 const wslLinkedKey = 'r1::/home/user/gone-worktree' writeDataFile({ repos: [makeRepo()], worktreeMeta: { [wslLinkedKey]: { displayName: '', comment: '', lastActivityAt: OLD } } }) await withPlatform('win32', async () => { const store = await createStore() expect(Object.keys(store.getAllWorktreeMeta())).toContain(wslLinkedKey) }) }) it.each([null, [], 5])('repairs a corrupt worktreeMeta map (%#)', async (worktreeMeta) => { writeDataFile({ worktreeMeta }) const store = await createStore() expect(store.getAllWorktreeMeta()).toEqual({}) store.flush() expect((readDataFile() as PersistedState).worktreeMeta).toEqual({}) }) // ── GitHub cache sidecar ─────────────────────────────────────────── it('cache refreshes never rewrite the durable state file', async () => { vi.useFakeTimers() try { const store = await createStore() store.updateUI({ sidebarWidth: 411 }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const inoBefore = statSync(dataFile()).ino expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 123 } as never }, issue: {} }) vi.advanceTimersByTime(6000) await store.waitForPendingWrite() expect(statSync(dataFile()).ino).toBe(inoBefore) } finally { vi.useRealTimers() } }) it('snapshots the cache at flush and seeds the next Store from the sidecar', async () => { const store = await createStore() store.setGitHubCache({ pr: { 'o/r#7': { fetchedAt: 7 } as never }, issue: {} }) store.flush() expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(true) const restarted = await createStore() expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 }) }) it('keeps GitHub cache sidecars scoped to explicit profile data files', async () => { const profileADir = join(testState.dir, 'profiles', 'a') const profileBDir = join(testState.dir, 'profiles', 'b') const profileADataFile = join(profileADir, 'orca-data.json') const profileBDataFile = join(profileBDir, 'orca-data.json') mkdirSync(profileADir, { recursive: true }) mkdirSync(profileBDir, { recursive: true }) vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() const profileAStore = new Store({ dataFile: profileADataFile }) profileAStore.setGitHubCache({ pr: { 'o/r#a': { fetchedAt: 10 } as never }, issue: {} }) profileAStore.flush() const profileBStore = new Store({ dataFile: profileBDataFile }) expect(profileBStore.getGitHubCache().pr['o/r#a']).toBeUndefined() profileBStore.setGitHubCache({ pr: { 'o/r#b': { fetchedAt: 20 } as never }, issue: {} }) profileBStore.flush() const restartedProfileA = new Store({ dataFile: profileADataFile }) const restartedProfileB = new Store({ dataFile: profileBDataFile }) expect(restartedProfileA.getGitHubCache().pr['o/r#a']).toEqual({ fetchedAt: 10 }) expect(restartedProfileA.getGitHubCache().pr['o/r#b']).toBeUndefined() expect(restartedProfileB.getGitHubCache().pr['o/r#b']).toEqual({ fetchedAt: 20 }) }) it('keeps a legacy in-file cache as the seed and strips it from disk', async () => { writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } }) const store = await createStore() expect(store.getGitHubCache().pr.legacy).toEqual({ fetchedAt: 1 }) // The legacy key marks the state dirty at load; the next write drops it. store.flush() expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() }) // ── UI state ─────────────────────────────────────────────────────── it('updateUI merges partial updates', async () => { const store = await createStore() store.updateUI({ sidebarWidth: 400 }) const ui = store.getUI() expect(ui.sidebarWidth).toBe(400) expect(ui.groupBy).toBe('repo') // default preserved expect(ui.dismissedUpdateVersion).toBeNull() }) it('round-trips and normalizes the host-qualified manual repo order', async () => { const store = await createStore() store.updateUI({ manualRepoOrder: [ { hostId: 'runtime:node-b', repoId: 'shared' }, { hostId: 'bogus', repoId: 'ignored' }, { hostId: 'runtime:node-b', repoId: 'shared' }, { hostId: 'local', repoId: 'alpha' } ] as never }) store.flush() const reloaded = await createStore() expect(reloaded.getUI().manualRepoOrder).toEqual([ { hostId: 'runtime:node-b', repoId: 'shared' }, { hostId: 'local', repoId: 'alpha' } ]) }) it('updateUI persists sanitized per-worktree dotfile visibility', async () => { const store = await createStore() store.updateUI({ showDotfilesByWorktree: { 'repo-1::/repo': false, 'repo-2::/repo': true, 'repo-3::/repo': 'bad', constructor: false } as never }) expect(store.getUI().showDotfilesByWorktree).toEqual({ 'repo-1::/repo': false, 'repo-2::/repo': true }) }) it('updateUI skips save and notification when normalized UI is unchanged', async () => { vi.useFakeTimers() try { const store = await createStore() const notifications: PersistedState['ui'][] = [] store.updateUI({ sidebarWidth: 400, showDotfilesByWorktree: { 'repo-1::/repo': false }, featureTipsSeenIds: ['voice-dictation'], contextualToursSeenIds: ['tasks'], featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 1 } } }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const persistedBefore = readFileSync(dataFile(), 'utf-8') store.onUIChanged((ui) => notifications.push(ui)) store.updateUI({ sidebarWidth: 400, showDotfilesByWorktree: { 'repo-1::/repo': false }, featureTipsSeenIds: ['voice-dictation'], contextualToursSeenIds: ['tasks'], featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 1 } } }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() expect(notifications).toEqual([]) expect(readFileSync(dataFile(), 'utf-8')).toBe(persistedBefore) } finally { vi.useRealTimers() } }) it('writes compact JSON (no pretty-print indentation) that round-trips via JSON.parse', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'compact-repo', path: '/compact/repo' })) store.updateUI({ sidebarWidth: 321 }) store.flush() const raw = readFileSync(dataFile(), 'utf-8') // Compact payload: no newline-plus-indentation from JSON.stringify(_, null, 2). expect(raw).not.toMatch(/\n\s+"/) const parsed = JSON.parse(raw) as PersistedState expect(parsed.repos).toContainEqual( expect.objectContaining({ id: 'compact-repo', path: '/compact/repo' }) ) expect(parsed.ui.sidebarWidth).toBe(321) }) it('migrates missing rightSidebarOpen from the legacy default setting', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { rightSidebarOpenByDefault: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().rightSidebarOpen).toBe(false) }) it('migrates missing rightSidebarOpen to open when the legacy default was open', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { rightSidebarOpenByDefault: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().rightSidebarOpen).toBe(true) }) it('keeps explicit rightSidebarOpen authoritative over the legacy default setting', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { rightSidebarOpenByDefault: true }, ui: { rightSidebarOpen: false }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().rightSidebarOpen).toBe(false) }) it('preserves explicit rightSidebarTab in persisted UI', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { rightSidebarTab: 'checks' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().rightSidebarTab).toBe('checks') }) it('preserves explicit rightSidebarExplorerView in persisted UI', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { rightSidebarTab: 'explorer', rightSidebarExplorerView: 'search' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().rightSidebarTab).toBe('explorer') expect(store.getUI().rightSidebarExplorerView).toBe('search') }) it('maps legacy persisted search tab to the Explorer search view', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { rightSidebarTab: 'search' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().rightSidebarTab).toBe('search') expect(store.getUI().rightSidebarExplorerView).toBe('search') }) it('normalizes invalid rightSidebarTab in persisted UI', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { rightSidebarTab: 'bogus' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().rightSidebarTab).toBe('explorer') }) it('updateUI merges feature interactions instead of replacing stale snapshots', async () => { const store = await createStore() store.updateUI({ featureInteractions: { 'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 } } }) store.updateUI({ featureInteractions: { tasks: { firstInteractedAt: 200, interactionCount: 1 } } }) expect(store.getUI().featureInteractions).toEqual({ 'agent-browser-use': { firstInteractedAt: 100, interactionCount: 1 }, tasks: { firstInteractedAt: 200, interactionCount: 1 } }) }) it('updateUI merges contextual tour seen ids instead of replacing stale snapshots', async () => { const store = await createStore() store.updateUI({ contextualToursSeenIds: ['browser'] }) store.updateUI({ contextualToursSeenIds: ['workspace-agent-sessions', 'unknown', 'browser'] as never }) expect(store.getUI().contextualToursSeenIds).toEqual(['browser', 'workspace-agent-sessions']) }) it('normalizes malformed persisted feature discovery state on read', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'], contextualToursSeenIds: ['tasks', 'unknown', 'tasks'] as never, featureInteractions: { tasks: { firstInteractedAt: 100 }, automations: { firstInteractedAt: 150, interactionCount: 4 }, browser: { firstInteractedAt: Number.NaN }, unknown: { firstInteractedAt: 200 } } }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation']) expect(store.getUI().contextualToursSeenIds).toEqual(['tasks']) expect(store.getUI().featureInteractions).toEqual({ tasks: { firstInteractedAt: 100, interactionCount: 1 }, automations: { firstInteractedAt: 150, interactionCount: 4 } }) }) it('normalizes malformed main-owned feature telemetry bucket markers on read', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {}, featureInteractionTelemetryBuckets: { tasks: 'count_2', browser: 'count_4', unknown: 'count_1' } }) const store = await createStore() store.flush() const persisted = readDataFile() as PersistedState expect(persisted.featureInteractionTelemetryBuckets).toEqual({ tasks: 'count_2' }) }) it('does not expose or accept UI shadow writes for main-owned feature telemetry markers', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { featureInteractionTelemetryBuckets: { tasks: 'count_1000_plus' } }, githubCache: { pr: {}, issue: {} }, workspaceSession: {}, featureInteractionTelemetryBuckets: { tasks: 'count_2' } }) const store = await createStore() expect('featureInteractionTelemetryBuckets' in (store.getUI() as Record)).toBe( false ) store.updateUI({ featureInteractionTelemetryBuckets: { tasks: 'count_500_999' } } as never) store.flush() const persisted = readDataFile() as PersistedState & { ui: Record } expect(persisted.featureInteractionTelemetryBuckets).toEqual({ tasks: 'count_2' }) expect(persisted.ui.featureInteractionTelemetryBuckets).toBeUndefined() }) it('normalizes feature tip ids from direct UI writes', async () => { const store = await createStore() store.updateUI({ featureTipsSeenIds: ['voice-dictation', 'unknown-tip', 'voice-dictation'] as never }) expect(store.getUI().featureTipsSeenIds).toEqual(['voice-dictation']) }) it('recordFeatureInteraction increments from the current persisted UI state', async () => { const store = await createStore() store.updateUI({ featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 2 } } }) const ui = store.recordFeatureInteraction('tasks') expect(ui.featureInteractions?.tasks).toEqual({ firstInteractedAt: 100, interactionCount: 3 }) expect(store.getUI().featureInteractions?.tasks).toEqual({ firstInteractedAt: 100, interactionCount: 3 }) }) it('emits feature interaction telemetry only when a higher bucket is reached', async () => { const store = await createStore() store.recordFeatureInteraction('tasks') store.recordFeatureInteraction('tasks') store.recordFeatureInteraction('tasks') store.recordFeatureInteraction('tasks') store.flush() expect(trackMock).toHaveBeenCalledTimes(3) expect(trackMock).toHaveBeenNthCalledWith(1, 'feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', count_bucket: 'count_1', bucket_source: 'crossed_now', nth_repo_added: 2 }) expect(trackMock).toHaveBeenNthCalledWith(2, 'feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', count_bucket: 'count_2', bucket_source: 'crossed_now', nth_repo_added: 2 }) expect(trackMock).toHaveBeenNthCalledWith(3, 'feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', count_bucket: 'count_3_4', bucket_source: 'crossed_now', nth_repo_added: 2 }) expect((readDataFile() as PersistedState).featureInteractionTelemetryBuckets).toEqual({ tasks: 'count_3_4' }) }) it('emits one observed-existing bucket for pre-rollout interaction counts', async () => { const store = await createStore() store.updateUI({ featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 137 } } }) trackMock.mockClear() store.recordFeatureInteraction('tasks') store.recordFeatureInteraction('tasks') store.flush() expect(trackMock).toHaveBeenCalledTimes(1) expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', count_bucket: 'count_100_199', bucket_source: 'observed_existing', nth_repo_added: 2 }) expect((readDataFile() as PersistedState).featureInteractionTelemetryBuckets).toEqual({ tasks: 'count_100_199' }) }) it('emits only the top-coded observed-existing bucket for pre-rollout power users', async () => { const store = await createStore() store.updateUI({ featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 1200 } } }) trackMock.mockClear() store.recordFeatureInteraction('tasks') expect(trackMock).toHaveBeenCalledTimes(1) expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', count_bucket: 'count_1000_plus', bucket_source: 'observed_existing', nth_repo_added: 2 }) }) it('emits high bucket crossings once and ignores same-range increments', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 198 } } }, githubCache: { pr: {}, issue: {} }, workspaceSession: {}, featureInteractionTelemetryBuckets: { tasks: 'count_100_199' } }) const store = await createStore() store.recordFeatureInteraction('tasks') store.recordFeatureInteraction('tasks') expect(trackMock).toHaveBeenCalledTimes(1) expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', count_bucket: 'count_200_499', bucket_source: 'crossed_now', nth_repo_added: 2 }) }) it('does not emit for count 4 but emits the count_1000_plus crossing', async () => { const store = await createStore() store.recordFeatureInteraction('tasks') store.recordFeatureInteraction('tasks') store.recordFeatureInteraction('tasks') trackMock.mockClear() store.recordFeatureInteraction('tasks') expect(trackMock).not.toHaveBeenCalled() writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 999 } } }, githubCache: { pr: {}, issue: {} }, workspaceSession: {}, featureInteractionTelemetryBuckets: { tasks: 'count_500_999' } }) const reloaded = await createStore() reloaded.recordFeatureInteraction('tasks') expect(trackMock).toHaveBeenCalledTimes(1) expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', count_bucket: 'count_1000_plus', bucket_source: 'crossed_now', nth_repo_added: 2 }) }) it('dedupes against the persisted bucket marker', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { featureInteractions: { tasks: { firstInteractedAt: 100, interactionCount: 100 } } }, githubCache: { pr: {}, issue: {} }, workspaceSession: {}, featureInteractionTelemetryBuckets: { tasks: 'count_100_199' } }) const store = await createStore() store.recordFeatureInteraction('tasks') expect(trackMock).not.toHaveBeenCalled() }) it('updateUI preserves selected card properties from direct UI writes', async () => { const store = await createStore() store.updateUI({ worktreeCardProperties: ['inline-agents'] }) expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'inline-agents']) }) it('persists updater reminder metadata in UI state', async () => { const store = await createStore() store.updateUI({ dismissedUpdateVersion: '1.0.99', lastUpdateCheckAt: 1234 }) const ui = store.getUI() expect(ui.dismissedUpdateVersion).toBe('1.0.99') expect(ui.lastUpdateCheckAt).toBe(1234) }) it('normalizes default browser zoom UI writes', async () => { const store = await createStore() store.updateUI({ browserDefaultZoomLevel: 1.26 }) expect(store.getUI().browserDefaultZoomLevel).toBe(1.5) }) it('encrypts the Kagi session link on disk and decrypts it on load', async () => { const sessionLink = 'https://kagi.com/search?token=secret' const store = await createStore() store.updateUI({ browserKagiSessionLink: sessionLink }) store.flush() const persisted = readDataFile() as { ui: { browserKagiSessionLink: string } } expect(persisted.ui.browserKagiSessionLink).not.toBe(sessionLink) const reloaded = await createStore() expect(reloaded.getUI().browserKagiSessionLink).toBe(sessionLink) }) it('durably encrypts SSH PTY consumer ownership for process restart recovery', async () => { const store = await createStore() store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 1 } as never }, issue: {} }) store.upsertSshPtyConsumerRecovery({ targetId: 'ssh-1', clientInstanceId: 'client-1', serverBuildId: 'relay-build-1', clientGeneration: 3, ownerGeneration: 5, ownerLease: 'secret-owner-lease', outputFlowControl: { version: 1, windowSu: 256 * 1024 } }) const persisted = readDataFile() as { sshPtyConsumerRecoveries: { ownerLease: string }[] } expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).not.toBe('secret-owner-lease') expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(false) const reloaded = await createStore() expect(reloaded.getSshPtyConsumerRecovery('ssh-1')).toEqual({ targetId: 'ssh-1', clientInstanceId: 'client-1', serverBuildId: 'relay-build-1', clientGeneration: 3, ownerGeneration: 5, ownerLease: 'secret-owner-lease', outputFlowControl: { version: 1, windowSu: 256 * 1024 } }) }) it('drops decrypted SSH PTY owner leases that exceed the relay protocol bound', async () => { const oversizedLease = 'x'.repeat(513) writeDataFile({ ...getDefaultPersistedState(testState.dir), sshPtyConsumerRecoveries: [ { targetId: 'ssh-1', clientInstanceId: 'client-1', serverBuildId: 'relay-build-1', clientGeneration: 3, ownerGeneration: 5, ownerLease: Buffer.from(`encrypted:${oversizedLease}`, 'utf-8').toString('base64') } ] }) const store = await createStore() expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull() }) it('removes persisted SSH PTY consumer ownership with its target', async () => { const store = await createStore() store.addSshTarget({ id: 'ssh-1', label: 'SSH 1', host: 'example.test', port: 22, username: 'orca' }) store.upsertSshPtyConsumerRecovery({ targetId: 'ssh-1', clientInstanceId: 'client-1', serverBuildId: 'relay-build-1', clientGeneration: 3, ownerGeneration: 5, ownerLease: 'secret-owner-lease' }) store.removeSshTarget('ssh-1') expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull() }) it('keeps plaintext Kagi session links readable for migration from older builds', async () => { const sessionLink = 'https://kagi.com/search?token=secret' writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { browserKagiSessionLink: sessionLink }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().browserKagiSessionLink).toBe(sessionLink) }) it('preserves persisted smart sort value', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { sortBy: 'smart' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().sortBy).toBe('smart') }) it('migrates legacy recent sort to smart on first load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { sortBy: 'recent' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().sortBy).toBe('smart') expect(store.getUI()._sortBySmartMigrated).toBe(true) }) it('preserves new recent sort after migration flag is set', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { sortBy: 'recent', _sortBySmartMigrated: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().sortBy).toBe('recent') }) it('uses recent as the default sort for a fresh install (no persisted sortBy)', async () => { // Why: the legacy-recent→smart migration must gate on the raw persisted value, not the normalized default, or fresh installs get mis-migrated to 'smart'. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().sortBy).toBe('recent') }) it('defaults workspace board task status sync off and normalizes persisted values', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { syncTaskStatusFromWorkspaceBoard: 'yes' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(false) store.updateUI({ syncTaskStatusFromWorkspaceBoard: true }) expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(true) }) it('preserves workflows above 20 statuses across load, write, and restart', async () => { const imported = Array.from({ length: 21 }, (_, index) => ({ id: `state-${index + 1}`, label: `State ${index + 1}` })).toReversed() writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { workspaceStatuses: imported }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( imported.map((status) => status.id) ) const authored = Array.from({ length: 64 }, (_, index) => ({ id: `final-${String(index + 1).padStart(3, '0')}`, label: `Final ${index + 1}` })).toReversed() store.updateUI({ workspaceStatuses: authored }) store.flush() expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( authored.map((status) => status.id) ) expect( (readDataFile() as PersistedState).ui.workspaceStatuses?.map((status) => status.id) ).toEqual(authored.map((status) => status.id)) const restarted = await createStore() expect(restarted.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( authored.map((status) => status.id) ) }) it('repairs the known-bad reordered default workspace statuses once on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() const ui = store.getUI() expect(ui.workspaceStatuses?.map((status) => status.id)).toEqual([ 'todo', 'in-progress', 'in-review', 'completed' ]) expect(ui.workspaceStatuses?.at(-1)?.label).toBe('Done') expect(ui._workspaceStatusesDefaultOrderMigrated).toBe(true) expect(ui._workspaceStatusesDefaultWorkflowMigrated).toBe(true) store.flush() const persisted = readDataFile() as PersistedState expect(persisted.ui._workspaceStatusesDefaultOrderMigrated).toBe(true) expect(persisted.ui._workspaceStatusesReorderedDefaultRepaired).toBe(true) expect(persisted.ui._workspaceStatusesDefaultWorkflowMigrated).toBe(true) expect(persisted.ui._workspaceStatusesDefaultVisualsMigrated).toBe(true) expect(persisted.ui.workspaceStatuses?.map((status) => status.id)).toEqual([ 'todo', 'in-progress', 'in-review', 'completed' ]) expect(persisted.ui.workspaceStatuses?.at(-1)?.label).toBe('Done') }) it('repairs the known-bad reordered default statuses after old migration flags are set', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { workspaceStatuses: REORDERED_DONE_DEFAULT_WORKSPACE_STATUSES, _workspaceStatusesDefaultOrderMigrated: true, _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual([ 'todo', 'in-progress', 'in-review', 'completed' ]) expect(store.getUI().workspaceStatuses?.at(-1)?.label).toBe('Done') expect(store.getUI()._workspaceStatusesReorderedDefaultRepaired).toBe(true) }) it('migrates legacy default workspace status visuals and workflow once on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES, _workspaceStatusesDefaultOrderMigrated: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().workspaceStatuses).toEqual(WORKFLOW_DEFAULT_WORKSPACE_STATUSES) expect(store.getUI()._workspaceStatusesDefaultWorkflowMigrated).toBe(true) expect(store.getUI()._workspaceStatusesDefaultVisualsMigrated).toBe(true) store.flush() const persisted = readDataFile() as { ui?: { _workspaceStatusesDefaultWorkflowMigrated?: boolean _workspaceStatusesDefaultVisualsMigrated?: boolean } } expect(persisted.ui?._workspaceStatusesDefaultWorkflowMigrated).toBe(true) expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true) }) it('preserves legacy-looking workspace status visuals after the load migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES, _workspaceStatusesDefaultOrderMigrated: true, _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() const inProgress = store .getUI() .workspaceStatuses?.find((status) => status.id === 'in-progress') expect(inProgress).toMatchObject({ color: 'blue', icon: 'circle-dot' }) }) it('preserves intentionally reordered default workspace statuses after the load migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: { workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES, _workspaceStatusesDefaultOrderMigrated: true, _workspaceStatusesReorderedDefaultRepaired: true, _workspaceStatusesDefaultWorkflowMigrated: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual([ 'completed', 'in-review', 'in-progress', 'todo' ]) }) // ── terminalMacOptionAsAlt migration (issue #903) ─────────────────── it('migrates legacy "true" terminalMacOptionAsAlt to "auto" on first load', async () => { // Why: legacy 'true' (old default) is indistinguishable from an explicit choice; flip un-migrated installs to 'auto' so non-US layouts keep @ / € / [ ]. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalMacOptionAsAlt: 'true' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalMacOptionAsAlt).toBe('auto') expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) it('migrates inherited right-click paste to each platform default once', async () => { for (const [platform, expected] of [ ['win32', true], ['darwin', false], ['linux', false] ] as const) { await withPlatform(platform, async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalRightClickToPaste: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalRightClickToPaste).toBe(expected) expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true) }) } }) it('preserves an explicit Windows right-click paste opt-out during migration', async () => { await withPlatform('win32', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalRightClickToPaste: false }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalRightClickToPaste).toBe(false) expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true) }) }) it('preserves right-click paste choices after the platform migration', async () => { await withPlatform('darwin', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalRightClickToPaste: true, terminalRightClickToPasteDefaultedForPlatform: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalRightClickToPaste).toBe(true) }) }) it('migrates inherited terminal bar cursor defaults to block on first load', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalCursorStyle: 'bar' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalCursorStyle).toBe('block') expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true) }) it('preserves terminal cursor choices after the block-default migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalCursorStyle: 'bar', terminalCursorStyleDefaultedToBlock: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalCursorStyle).toBe('bar') expect(store.getSettings().terminalCursorStyleDefaultedToBlock).toBe(true) }) it('preserves explicit "false" terminalMacOptionAsAlt through migration', async () => { // 'false' never matched the old default — it was an explicit choice. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalMacOptionAsAlt: 'false' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalMacOptionAsAlt).toBe('false') expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) it('preserves explicit "left" / "right" terminalMacOptionAsAlt through migration', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalMacOptionAsAlt: 'left' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalMacOptionAsAlt).toBe('left') expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) it('respects already-migrated settings with explicit "true"', async () => { // A deliberate 'true' ('Both') choice post-migration is preserved on later launches. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { terminalMacOptionAsAlt: 'true', terminalMacOptionAsAltMigrated: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalMacOptionAsAlt).toBe('true') expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) it('fresh install defaults terminalMacOptionAsAlt to "auto" and marks migrated', async () => { // No data file: 'auto' is the new default and migration is complete (nothing legacy to migrate). const store = await createStore() expect(store.getSettings().terminalMacOptionAsAlt).toBe('auto') // Fresh install: migrated stays false (migration code never ran); a later load with legacy 'true' still migrates correctly. expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(false) }) it('missing terminalMacOptionAsAlt in persisted file defaults to "auto" and flags migrated', async () => { // Existing file predates the setting: land on 'auto' and mark migrated so we don't re-examine. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().terminalMacOptionAsAlt).toBe('auto') expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) it('migrates the legacy experimentalSidekick setting to experimentalPet', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { experimentalSidekick: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().experimentalPet).toBe(true) }) it('migrates the legacy experimental compact worktree cards setting', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { experimentalCompactWorktreeCards: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().compactWorktreeCards).toBe(true) expect(store.getSettings().experimentalCompactWorktreeCards).toBeUndefined() }) it('defaults legacy experimentalActivity profiles off once', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { experimentalActivity: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().experimentalActivity).toBe(false) expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true) }) it('preserves experimentalActivity after the default-off migration has run', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { experimentalActivity: true, experimentalActivityDefaultedOffForAllUsers: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().experimentalActivity).toBe(true) }) // ── worktree-card property migration ─────────────────────────────── it('adds split-out default card properties for legacy detailed profiles', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: false }, ui: { worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'] }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).toEqual([ 'status', 'unread', 'ci', 'issue', 'linear-issue', 'jira-issue', 'pr', 'comment', 'ports', 'inline-agents' ]) expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true) }) it('adds split-out default card properties without duplicating inline agents', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: true }, ui: { worktreeCardProperties: [ 'status', 'unread', 'ci', 'issue', 'pr', 'comment', 'inline-agents' ] }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).toEqual([ 'status', 'unread', 'ci', 'issue', 'linear-issue', 'jira-issue', 'pr', 'comment', 'ports', 'inline-agents' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true) }) it('derives fresh default profiles without branch', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).toEqual([ 'status', 'unread', 'issue', 'linear-issue', 'jira-issue', 'pr', 'automation', 'cli', 'comment', 'ports', 'inline-agents' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI()._worktreeCardModeDefaulted).toBe(true) }) it('adds split-out defaults even when the mode marker exists but expansion has not run', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: false }, ui: { worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr'], _worktreeCardModeDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).toEqual([ 'status', 'unread', 'ci', 'issue', 'linear-issue', 'jira-issue', 'pr', 'ports', 'inline-agents' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') }) it('preserves deliberate post-migration card property opt-outs', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: false }, ui: { worktreeCardProperties: ['status', 'pr'], _inlineAgentsDefaultedForAllUsers: true, _expandedWorktreeCardPropertiesDefaulted: true, _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'pr']) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI().worktreeCardProperties).not.toContain('ports') expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') }) it('does not re-add branch after an explicit Default mode selection', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: false }, ui: { worktreeCardProperties: [ 'status', 'unread', 'issue', 'linear-issue', 'pr', 'comment', 'ports', 'inline-agents' ], _inlineAgentsDefaultedForAllUsers: true, _expandedWorktreeCardPropertiesDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI().worktreeCardProperties).toContain('inline-agents') }) it('preserves explicit Compact card properties after expansion has run', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: true }, ui: { worktreeCardProperties: [ 'status', 'unread', 'issue', 'linear-issue', 'pr', 'comment', 'ports' ], _inlineAgentsDefaultedForAllUsers: true, _expandedWorktreeCardPropertiesDefaulted: true, _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().compactWorktreeCards).toBe(true) expect(store.getUI().worktreeCardProperties).toEqual([ 'status', 'unread', 'issue', 'linear-issue', 'pr', 'comment', 'ports' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') }) it('backfills jira-issue once for profiles stamped before it joined the defaults', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: false }, ui: { worktreeCardProperties: [ 'status', 'unread', 'issue', 'linear-issue', 'pr', 'ports', 'inline-agents' ], _inlineAgentsDefaultedForAllUsers: true, _expandedWorktreeCardPropertiesDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).toEqual([ 'status', 'unread', 'issue', 'linear-issue', 'jira-issue', 'pr', 'ports', 'inline-agents' ]) expect( store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') ).toHaveLength(1) expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) }) it('preserves a deliberate jira-issue removal after the backfill has run', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: false }, ui: { worktreeCardProperties: ['status', 'unread', 'issue', 'linear-issue', 'pr'], _inlineAgentsDefaultedForAllUsers: true, _expandedWorktreeCardPropertiesDefaulted: true, _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).toEqual([ 'status', 'unread', 'issue', 'linear-issue', 'pr' ]) expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') }) it('leaves fresh default profiles with a single jira-issue entry', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect( store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') ).toHaveLength(1) expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) }) it('skips the jira-issue backfill when card properties are malformed', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: false }, ui: { worktreeCardProperties: 'not-an-array' }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) }) it('uses the compact preset when card properties are missing in compact mode', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: true }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().compactWorktreeCards).toBe(true) expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread']) expect(store.getUI().worktreeCardProperties).not.toContain('automation') }) it('preserves the current defaulted Compact preset without expanding display toggles', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }, ui: { worktreeCardProperties: ['status', 'unread'], _worktreeCardModeDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().compactWorktreeCards).toBe(true) expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread']) expect(store.getUI().worktreeCardProperties).not.toContain('ports') expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') }) it.each([ ['raw', ['status', 'automation']], ['normalized', ['status', 'unread', 'automation']] ] as const)( 'migrates the old %s defaulted compact preset without automation', async (_, props) => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { compactWorktreeCards: true }, ui: { worktreeCardProperties: [...props], _worktreeCardModeDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().compactWorktreeCards).toBe(true) expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread']) expect(store.getUI().worktreeCardProperties).not.toContain('automation') expect(store.getUI()._worktreeCardModeDefaulted).toBe(true) } ) // ── GitHub Cache ─────────────────────────────────────────────────── it('get/set GitHub cache round-trips', async () => { const store = await createStore() const cache = { pr: { 'owner/repo#1': { data: null, fetchedAt: 1000 } }, issue: {} } store.setGitHubCache(cache) expect(store.getGitHubCache()).toEqual(cache) }) // ── Workspace Session ────────────────────────────────────────────── it('get/set workspace session round-trips', async () => { const store = await createStore() const session = { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: {}, terminalLayoutsByTabId: {} } store.setWorkspaceSession(session) expect(store.getWorkspaceSession()).toEqual(session) }) it('patches workspace session without replacing unchanged slices', async () => { const store = await createStore() const tabsByWorktree = { wt1: [makeTerminalTab({ id: 'tab1', ptyId: null, worktreeId: 'wt1' })] } const terminalLayoutsByTabId = { tab1: { root: null, activeLeafId: null, expandedLeafId: null } } store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree, terminalLayoutsByTabId, activeConnectionIdsAtShutdown: ['ssh-1'] }) store.patchWorkspaceSession({ activeTabId: 'tab2', activeConnectionIdsAtShutdown: undefined }) const session = store.getWorkspaceSession() expect(session.activeTabId).toBe('tab2') expect(session.tabsByWorktree).toEqual(tabsByWorktree) expect(session.terminalLayoutsByTabId).toEqual(terminalLayoutsByTabId) expect(session.activeConnectionIdsAtShutdown).toBeUndefined() }) it('uses full normalization for structural workspace session patches', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'local-repo', connectionId: null })) store.setWorkspaceSession({ activeRepoId: 'local-repo', activeWorktreeId: 'local-repo::/worktree', activeTabId: 'tab-local', tabsByWorktree: { 'local-repo::/worktree': [ makeTerminalTab({ id: 'tab-local', ptyId: 'pty-local', worktreeId: 'local-repo::/worktree' }) ] }, terminalLayoutsByTabId: {} }) store.patchWorkspaceSession({ terminalLayoutsByTabId: { 'tab-local': { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, buffersByLeafId: { [TEST_LEAF_1]: 'local-scrollback' }, ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-local' } } } }) expect( store.getWorkspaceSession().terminalLayoutsByTabId['tab-local'].buffersByLeafId ).toBeUndefined() }) it('stores remote terminal scrollback out of workspace session JSON', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'local-repo', connectionId: null })) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) store.setWorkspaceSession(makeSessionWithTerminalBuffers()) const session = store.getWorkspaceSession() expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined() expect(session.terminalLayoutsByTabId['local-tab'].ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'local-pty' }) expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined() expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({ [TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) }) const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2] expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback') }) it('stores terminal scrollback snapshots beside explicit profile data files', async () => { const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') const profileDataFile = join(profileDataDirectory, 'orca-data.json') mkdirSync(profileDataDirectory, { recursive: true }) vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) const session = makeSessionWithTerminalBuffers() store.setWorkspaceSession({ ...session, tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] }, terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] } }) const ref = store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ TEST_LEAF_2 ] expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/)) expect(existsSync(join(profileDataDirectory, 'terminal-scrollback', `${ref}.bin`))).toBe(true) expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) }) it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => { const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') const profileDataFile = join(profileDataDirectory, 'orca-data.json') const ref = 'v1-11111111111111111111111111111111' const legacySnapshotDir = join(testState.dir, 'terminal-scrollback') mkdirSync(profileDataDirectory, { recursive: true }) mkdirSync(legacySnapshotDir, { recursive: true }) writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) expect(store.readTerminalScrollbackSnapshot(ref)).toBe('legacy-scrollback') }) it('caps oversized browser history when setting workspace session', async () => { const store = await createStore() const oversizedSession = makeSessionWithBrowserHistory(500) const oversizedBytes = Buffer.byteLength(JSON.stringify(oversizedSession)) store.setWorkspaceSession(oversizedSession) const session = store.getWorkspaceSession() const prunedBytes = Buffer.byteLength(JSON.stringify(session)) expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES) expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199') expect(prunedBytes).toBeLessThan(oversizedBytes / 2) }) it('stores maybe-remote terminal scrollback out of workspace session JSON', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'remote-repo', activeWorktreeId: 'remote-repo::/remote', activeTabId: 'remote-tab', tabsByWorktree: { 'remote-repo::/remote': [ makeTerminalTab({ id: 'remote-tab', ptyId: 'remote-pty', worktreeId: 'remote-repo::/remote' }) ] }, terminalLayoutsByTabId: { 'remote-tab': { root: { type: 'leaf', leafId: TEST_LEAF_2 }, activeLeafId: TEST_LEAF_2, expandedLeafId: null, buffersByLeafId: { [TEST_LEAF_2]: 'maybe-remote-scrollback' } } } }) expect( store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].buffersByLeafId ).toBeUndefined() expect( store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId ).toEqual({ [TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) }) const ref = store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ TEST_LEAF_2 ] expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('maybe-remote-scrollback') }) it('deletes terminal scrollback snapshot files when refs leave the workspace session', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) const session = makeSessionWithTerminalBuffers() store.setWorkspaceSession({ ...session, tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] }, terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] } }) const ref = store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ TEST_LEAF_2 ] expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/)) if (!ref) { throw new Error('expected scrollback snapshot ref') } expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true) store.setWorkspaceSession({ activeRepoId: null, activeWorktreeId: null, activeTabId: null, tabsByWorktree: {}, terminalLayoutsByTabId: {} }) expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) }) it('reads only the replay tail from oversized terminal scrollback snapshots', async () => { const store = await createStore() const ref = 'v1-00000000000000000000000000000000' const snapshotDir = join(testState.dir, 'terminal-scrollback') mkdirSync(snapshotDir, { recursive: true }) writeFileSync( join(snapshotDir, `${ref}.bin`), `stale-prefix-${'x'.repeat(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)}tail`, 'utf-8' ) const buffer = store.readTerminalScrollbackSnapshot(ref) expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) expect(buffer?.startsWith('stale-prefix')).toBe(false) expect(buffer?.endsWith('tail')).toBe(true) }) it('strips legacy local terminal scrollback buffers when loading workspace session', async () => { writeDataFile({ schemaVersion: 1, repos: [ makeRepo({ id: 'local-repo', connectionId: null }), makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }) ], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: makeSessionWithTerminalBuffers() }) const store = await createStore() const session = store.getWorkspaceSession() expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined() expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined() expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({ [TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) }) const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2] expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback') }) it('caps oversized legacy browser history when loading workspace session', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: makeSessionWithBrowserHistory(500) }) const store = await createStore() const session = store.getWorkspaceSession() expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES) expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199') }) it('remaps legacy SSH lease leaf ids when loading legacy workspace layouts', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: 'pane:1' }, activeLeafId: 'pane:1', expandedLeafId: null, ptyIdsByLeafId: { 'pane:1': 'remote-pty' } } } }, sshRemotePtyLeases: [ { targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: 'pane:1', state: 'detached', createdAt: 1, updatedAt: 1 } ] }) const store = await createStore() const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null if (leafId === null) { throw new Error('Expected remapped leaf id') } expect(isTerminalLeafId(leafId)).toBe(true) expect(layout.ptyIdsByLeafId).toEqual({ [leafId]: 'remote-pty' }) expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId) }) it('hydrates legacy numeric agent status cache through the pane identity migration', async () => { const agentHooksDir = join(testState.dir, 'agent-hooks') mkdirSync(agentHooksDir, { recursive: true }) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: 'pane:1' }, activeLeafId: 'pane:1', expandedLeafId: null, ptyIdsByLeafId: { 'pane:1': 'local-pty' } } } } }) writeFileSync( join(agentHooksDir, 'last-status.json'), JSON.stringify({ version: 2, entries: { 'tab1:1': { paneKey: 'tab1:1', tabId: 'tab1', worktreeId: 'wt1', connectionId: null, receivedAt: Date.now(), stateStartedAt: Date.now() - 1000, payload: { state: 'working', prompt: 'legacy numeric prompt', agentType: 'claude' } } } }), 'utf-8' ) const store = await createStore() const { agentHookServer } = await import('./agent-hooks/server') await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) try { const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null if (leafId === null) { throw new Error('Expected remapped leaf id') } const stablePaneKey = makePaneKey('tab1', leafId) expect(agentHookServer.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: stablePaneKey, tabId: 'tab1', worktreeId: 'wt1', state: 'working', prompt: 'legacy numeric prompt', agentType: 'claude' }) ]) } finally { agentHookServer.stop() } }) it('hydrates split-pane legacy numeric agent status rows onto the matching remapped leaves', async () => { const agentHooksDir = join(testState.dir, 'agent-hooks') mkdirSync(agentHooksDir, { recursive: true }) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: 'pane:1' }, second: { type: 'leaf', leafId: 'pane:2' }, sizes: [50, 50] }, activeLeafId: 'pane:1', expandedLeafId: null, ptyIdsByLeafId: { 'pane:1': 'local-pty-1', 'pane:2': 'local-pty-2' } } } } }) const now = Date.now() writeFileSync( join(agentHooksDir, 'last-status.json'), JSON.stringify({ version: 2, entries: { 'tab1:1': { paneKey: 'tab1:1', tabId: 'tab1', worktreeId: 'wt1', connectionId: null, receivedAt: now, stateStartedAt: now - 2000, payload: { state: 'working', prompt: 'left legacy prompt', agentType: 'claude' } }, 'tab1:2': { paneKey: 'tab1:2', tabId: 'tab1', worktreeId: 'wt1', connectionId: null, receivedAt: now, stateStartedAt: now - 1000, payload: { state: 'blocked', prompt: 'right legacy prompt', agentType: 'codex' } } } }), 'utf-8' ) const store = await createStore() const { agentHookServer } = await import('./agent-hooks/server') await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) try { const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 const firstLeafId = layout.root?.type === 'split' && layout.root.first.type === 'leaf' ? layout.root.first.leafId : null const secondLeafId = layout.root?.type === 'split' && layout.root.second.type === 'leaf' ? layout.root.second.leafId : null if (firstLeafId === null || secondLeafId === null) { throw new Error('Expected remapped split leaves') } const byPaneKey = new Map( agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) ) expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( expect.objectContaining({ state: 'working', prompt: 'left legacy prompt', agentType: 'claude' }) ) expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( expect.objectContaining({ state: 'blocked', prompt: 'right legacy prompt', agentType: 'codex' }) ) } finally { agentHookServer.stop() } }) it('hydrates split-pane legacy status rows even when PTY leaf bindings are absent', async () => { const agentHooksDir = join(testState.dir, 'agent-hooks') mkdirSync(agentHooksDir, { recursive: true }) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'vertical', first: { type: 'leaf', leafId: 'pane:1' }, second: { type: 'leaf', leafId: 'pane:2' }, sizes: [50, 50] }, activeLeafId: 'pane:2', expandedLeafId: null } } } }) const now = Date.now() writeFileSync( join(agentHooksDir, 'last-status.json'), JSON.stringify({ version: 2, entries: { 'tab1:1': { paneKey: 'tab1:1', tabId: 'tab1', worktreeId: 'wt1', connectionId: null, receivedAt: now, stateStartedAt: now - 2000, payload: { state: 'working', prompt: 'left no binding', agentType: 'claude' } }, 'tab1:2': { paneKey: 'tab1:2', tabId: 'tab1', worktreeId: 'wt1', connectionId: null, receivedAt: now, stateStartedAt: now - 1000, payload: { state: 'blocked', prompt: 'right no binding', agentType: 'codex' } } } }), 'utf-8' ) const store = await createStore() const { agentHookServer } = await import('./agent-hooks/server') await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) try { const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 const firstLeafId = layout.root?.type === 'split' && layout.root.first.type === 'leaf' ? layout.root.first.leafId : null const secondLeafId = layout.root?.type === 'split' && layout.root.second.type === 'leaf' ? layout.root.second.leafId : null if (firstLeafId === null || secondLeafId === null) { throw new Error('Expected remapped split leaves') } const byPaneKey = new Map( agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) ) expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( expect.objectContaining({ state: 'working', prompt: 'left no binding', agentType: 'claude' }) ) expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( expect.objectContaining({ state: 'blocked', prompt: 'right no binding', agentType: 'codex' }) ) } finally { agentHookServer.stop() } }) it('persists legacy pane-key aliases after the layout has been normalized', async () => { const agentHooksDir = join(testState.dir, 'agent-hooks') mkdirSync(agentHooksDir, { recursive: true }) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: 'pane:1' }, activeLeafId: 'pane:1', expandedLeafId: null, ptyIdsByLeafId: { 'pane:1': 'local-pty' } } } } }) const firstStore = await createStore() const root = firstStore.getWorkspaceSession().terminalLayoutsByTabId.tab1.root const stableLeafId = root?.type === 'leaf' ? root.leafId : null if (stableLeafId === null) { throw new Error('Expected remapped leaf id') } const stablePaneKey = makePaneKey('tab1', stableLeafId) firstStore.flush() expect(readDataFile()).toEqual( expect.objectContaining({ legacyPaneKeyAliasEntries: [ expect.objectContaining({ ptyId: 'local-pty', legacyPaneKey: 'tab1:1', stablePaneKey }) ] }) ) const now = Date.now() writeFileSync( join(agentHooksDir, 'last-status.json'), JSON.stringify({ version: 2, entries: { 'tab1:1': { paneKey: 'tab1:1', tabId: 'tab1', worktreeId: 'wt1', connectionId: null, receivedAt: now, stateStartedAt: now - 1000, payload: { state: 'working', prompt: 'post-normalize legacy prompt' } } } }), 'utf-8' ) await createStore() const { agentHookServer } = await import('./agent-hooks/server') await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) try { expect(agentHookServer.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: stablePaneKey, state: 'working', prompt: 'post-normalize legacy prompt' }) ]) } finally { agentHookServer.stop() } }) it('restores cross-tab pane authority aliases before hook ingestion', async () => { const physicalPaneKey = makePaneKey('tab-source', TEST_LEAF_1) const ownerPaneKey = makePaneKey('tab-target', TEST_LEAF_2) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, legacyPaneKeyAliasEntries: [ { ptyId: 'pty-detached', legacyPaneKey: physicalPaneKey, stablePaneKey: ownerPaneKey, updatedAt: 10 } ] }) await createStore() const { agentHookServer } = await import('./agent-hooks/server') agentHookServer.ingestTerminalStatus({ paneKey: physicalPaneKey, tabId: 'tab-source', worktreeId: 'wt1', payload: { state: 'working', prompt: 'detached after restart' } }) expect(agentHookServer.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: ownerPaneKey, tabId: 'tab-target', prompt: 'detached after restart' }) ]) }) it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'vertical', first: { type: 'leaf', leafId: 'pane:1' }, second: { type: 'leaf', leafId: 'pane:2' }, sizes: [50, 50] }, activeLeafId: 'pane:2', expandedLeafId: null } } } }) const store = await createStore() const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 const firstLeafId = layout.root?.type === 'split' && layout.root.first.type === 'leaf' ? layout.root.first.leafId : null const secondLeafId = layout.root?.type === 'split' && layout.root.second.type === 'leaf' ? layout.root.second.leafId : null if ( !firstLeafId || !secondLeafId || !isTerminalLeafId(firstLeafId) || !isTerminalLeafId(secondLeafId) ) { throw new Error('Expected remapped split leaf ids') } const activePaneKey = makePaneKey('tab1', secondLeafId) const firstPaneKey = makePaneKey('tab1', firstLeafId) const secondPaneKey = makePaneKey('tab1', secondLeafId) store.flush() expect(readDataFile()).toEqual( expect.objectContaining({ legacyPaneKeyAliasEntries: expect.arrayContaining([ expect.objectContaining({ ptyId: 'local-pty', legacyPaneKey: 'tab1:0', stablePaneKey: activePaneKey }), expect.objectContaining({ ptyId: 'local-pty', legacyPaneKey: 'tab1:1', stablePaneKey: firstPaneKey }), expect.objectContaining({ ptyId: 'local-pty', legacyPaneKey: 'tab1:2', stablePaneKey: secondPaneKey }) ]) }) ) }) it('loads legacy pane aliases from very large persisted split layouts', async () => { const leafCount = 130_000 writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'large-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: makeBalancedLegacyPaneLayout(0, leafCount), activeLeafId: 'pane:1', expandedLeafId: null } } } }) const store = await createStore() store.flush() const persisted = readDataFile() as PersistedState const aliasEntries = persisted.legacyPaneKeyAliasEntries expect(aliasEntries).toHaveLength(leafCount + 1) expect( aliasEntries.some((entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === 'tab1:0') ).toBe(true) expect( aliasEntries.some((entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === 'tab1:1') ).toBe(true) expect( aliasEntries.some( (entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === `tab1:${leafCount}` ) ).toBe(true) }) it('converts unambiguous dev migration rows into persisted aliases', async () => { const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1) writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty' } } } }, migrationUnsupportedPtyEntries: [ { ptyId: 'local-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, paneKey: stablePaneKey, reason: 'legacy-numeric-pane-key', source: 'local', updatedAt: 123 } ] }) const store = await createStore() store.flush() expect(readDataFile()).toEqual( expect.objectContaining({ migrationUnsupportedPtyEntries: [], legacyPaneKeyAliasEntries: expect.arrayContaining([ expect.objectContaining({ ptyId: 'local-pty', legacyPaneKey: 'tab1:0', stablePaneKey }), expect.objectContaining({ ptyId: 'local-pty', legacyPaneKey: 'tab1:1', stablePaneKey }) ]) }) ) }) it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }, sshRemotePtyLeases: [ { targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: 'pane:1', state: 'detached', createdAt: 1, updatedAt: 1 } ] }) const store = await createStore() expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(TEST_LEAF_1) }) it('normalizes stale legacy session writes to prior UUID leaves before preserving bindings', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: 'pane:1' }, activeLeafId: 'pane:1', expandedLeafId: null, ptyIdsByLeafId: {} } } }) const session = store.getWorkspaceSession() const layout = session.terminalLayoutsByTabId.tab1 expect(layout.root).toEqual({ type: 'leaf', leafId: TEST_LEAF_1 }) expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'remote-pty' }) expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') }) it('promotes an empty tab layout to a durable UUID root when persisting the first PTY binding', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: null, activeLeafId: null, expandedLeafId: null } } }) store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, ptyId: 'daemon-pty' }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') expect(session.terminalLayoutsByTabId.tab1).toEqual({ root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'daemon-pty' } }) }) it('admits a fresh host spawn after retirement while rejecting an older renderer topology', async () => { const store = await createStore() store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), terminalTopologyRevisionByRepoId: { wt1: 1 } }) store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_1, ptyId: 'fresh-pty', incarnationId: 'fresh-incarnation' }) const admitted = structuredClone(store.getWorkspaceSession()) expect(admitted.terminalTopologyRevisionByRepoId?.wt1).toBe(2) expect(admitted.tabsByWorktree.wt1).toEqual([ expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) ]) store.setWorkspaceSession({ ...admitted, tabsByWorktree: { ...admitted.tabsByWorktree, wt1: admitted.tabsByWorktree.wt1.map((tab) => ({ ...tab, title: 'Fresh title', sortOrder: 7 })) }, terminalLayoutsByTabId: { ...admitted.terminalLayoutsByTabId, 'fresh-tab': { ...admitted.terminalLayoutsByTabId['fresh-tab'], titlesByLeafId: { [TEST_LEAF_1]: 'Fresh pane title' } } } }) expect(store.getWorkspaceSession().tabsByWorktree.wt1[0]).toMatchObject({ id: 'fresh-tab', ptyId: 'fresh-pty', title: 'Fresh title', sortOrder: 7 }) expect(store.getWorkspaceSession().terminalLayoutsByTabId['fresh-tab'].titlesByLeafId).toEqual({ [TEST_LEAF_1]: 'Fresh pane title' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { wt1: [ { id: 'retired-tab', worktreeId: 'wt1', title: 'Retired', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'retired-pty' } ] }, terminalLayoutsByTabId: { 'retired-tab': { root: { type: 'leaf', leafId: TEST_LEAF_2 }, activeLeafId: TEST_LEAF_2, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_2]: 'retired-pty' } } }, terminalPtyIncarnationsByPaneKey: { [`retired-tab:${TEST_LEAF_2}`]: 'retired-incarnation' }, terminalTopologyRevisionByRepoId: { wt1: 1 } }) const afterStaleWrite = store.getWorkspaceSession() expect(afterStaleWrite.tabsByWorktree.wt1).toEqual([ expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) ]) expect(afterStaleWrite.terminalLayoutsByTabId['retired-tab']).toBeUndefined() expect( afterStaleWrite.terminalPtyIncarnationsByPaneKey?.[`retired-tab:${TEST_LEAF_2}`] ).toBeUndefined() }) it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } } } }) store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, ptyId: 'pty-2' }) const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 expect(layout.root).toEqual({ type: 'split', direction: 'vertical', first: { type: 'leaf', leafId: TEST_LEAF_1 }, second: { type: 'leaf', leafId: TEST_LEAF_2 } }) expect(layout.activeLeafId).toBe(TEST_LEAF_2) expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'pty-1', [TEST_LEAF_2]: 'pty-2' }) const reloaded = await createStore() expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'pty-1', [TEST_LEAF_2]: 'pty-2' }) }) it('advances host topology when a live spawn adds a split leaf after retirement', async () => { const store = await createStore() store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } } }, terminalTopologyRevisionByRepoId: { wt1: 1 } }) store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, ptyId: 'pty-2' }) const session = store.getWorkspaceSession() expect(session.terminalTopologyRevisionByRepoId?.wt1).toBe(2) expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'pty-1', [TEST_LEAF_2]: 'pty-2' }) }) it('keeps worktree deletion authoritative against stale writes and later same-path reuse', async () => { const store = await createStore() store.setWorktreeMeta('wt1', { displayName: 'Worktree' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { wt1: [makeTerminalTab({ id: 'old-tab', worktreeId: 'wt1', ptyId: 'old-pty' })] }, terminalLayoutsByTabId: { 'old-tab': { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'old-pty' } } }, terminalPtyIncarnationsByPaneKey: { [`old-tab:${TEST_LEAF_1}`]: 'old-incarnation' }, terminalTopologyRevisionByRepoId: { wt1: 1 } }) const stale = structuredClone(store.getWorkspaceSession()) store.removeWorktreeMeta('wt1') expect(store.getWorkspaceSession().tabsByWorktree.wt1).toBeUndefined() expect(store.getWorkspaceSession().terminalLayoutsByTabId['old-tab']).toBeUndefined() expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(2) store.setWorkspaceSession(stale) expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([]) store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_2, ptyId: 'fresh-pty', incarnationId: 'fresh-incarnation' }) expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([ expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) ]) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(3) }) it('fences a delayed terminal snapshot after an empty worktree is deleted', async () => { const store = await createStore() const worktreeId = 'repo::/empty-worktree' store.setWorktreeMeta(worktreeId, { displayName: 'Empty worktree' }) const stale = { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'late-tab', worktreeId, ptyId: 'late-pty' })] } } store.removeWorktreeMeta(worktreeId) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(1) store.setWorkspaceSession(stale) expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) }) it('advances deletion authority when only a legacy retirement fence existed', async () => { const store = await createStore() const worktreeId = 'repo::/legacy-tombstone' store.setWorktreeMeta(worktreeId, { displayName: 'Legacy worktree' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), terminalSurfaceTombstonesByPaneKey: { 'old-tab:old-leaf': { worktreeId, parentTabId: 'old-tab', leafId: 'old-leaf', ptyId: 'old-pty', incarnationId: 'old-incarnation', retiredAt: 1 } } }) const revisionBeforeDelete = store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo ?? 0 store.removeWorktreeMeta(worktreeId) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe( revisionBeforeDelete + 1 ) }) it('enforces one repo epoch across siblings and admits a fresh sibling spawn', async () => { const store = await createStore() const worktreeA = 'repo::/worktree-a' const worktreeB = 'repo::/worktree-b' store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeA]: [makeTerminalTab({ id: 'tab-a', worktreeId: worktreeA, ptyId: 'pty-a' })], [worktreeB]: [makeTerminalTab({ id: 'tab-b', worktreeId: worktreeB, ptyId: 'pty-b' })] }, terminalTopologyRevisionByRepoId: { repo: 1 } }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeB]: [makeTerminalTab({ id: 'stale-tab', worktreeId: worktreeB })] } }) expect(store.getWorkspaceSession().tabsByWorktree[worktreeA]?.[0]?.id).toBe('tab-a') expect(store.getWorkspaceSession().tabsByWorktree[worktreeB]?.[0]?.id).toBe('tab-b') store.persistPtyBinding({ worktreeId: worktreeB, tabId: 'fresh-tab', leafId: TEST_LEAF_1, ptyId: 'fresh-pty' }) expect(store.getWorkspaceSession().tabsByWorktree[worktreeB].map((tab) => tab.id)).toEqual([ 'tab-b', 'fresh-tab' ]) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(2) }) it('keeps one deletion watermark for many historical worktrees in the same repo', async () => { const store = await createStore() for (let index = 0; index < 25; index += 1) { const worktreeId = `repo::/worktree-${index}` store.setWorktreeMeta(worktreeId, { displayName: `Worktree ${index}` }) store.persistPtyBinding({ worktreeId, tabId: `tab-${index}`, leafId: TEST_LEAF_1, ptyId: `pty-${index}` }) store.removeWorktreeMeta(worktreeId) } const session = store.getWorkspaceSession() expect(Object.keys(session.terminalTopologyRevisionByRepoId ?? {})).toEqual(['repo']) expect(session.tabsByWorktree).toEqual({}) }) it('does not remove colliding worktree ids from other execution-host partitions', async () => { const store = await createStore() const worktreeId = 'repo::/same-path' store.setWorktreeMeta(worktreeId, { displayName: 'Local worktree' }) store.setWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'local-tab', worktreeId })] } }) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'remote-a-tab', worktreeId })] } }, 'runtime:env-a' ) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'remote-b-tab', worktreeId })] } }, 'runtime:env-b' ) store.removeWorktreeMeta(worktreeId) expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toBeUndefined() expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]?.[0]?.id).toBe( 'remote-a-tab' ) expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]?.[0]?.id).toBe( 'remote-b-tab' ) }) it('preserves a sync-persisted UUID root when a stale empty layout write arrives', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: null, activeLeafId: null, expandedLeafId: null } } }) store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, ptyId: 'daemon-pty' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: null, activeLeafId: null, expandedLeafId: null, ptyIdsByLeafId: {} } } }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') expect(session.terminalLayoutsByTabId.tab1).toEqual({ root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'daemon-pty' } }) }) it('drops legacy leaf-keyed records from mixed-version writes before binding preservation', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'daemon-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'daemon-pty' }, buffersByLeafId: { [TEST_LEAF_1]: 'Current buffer' }, titlesByLeafId: { [TEST_LEAF_1]: 'Current' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: 'pane:1', expandedLeafId: 'pane:1', ptyIdsByLeafId: { 'pane:1': 'stale-pty' }, buffersByLeafId: { 'pane:1': 'Stale buffer' }, titlesByLeafId: { 'pane:1': 'Stale' } } } }) const session = store.getWorkspaceSession() const layout = session.terminalLayoutsByTabId.tab1 expect(layout.activeLeafId).toBe(TEST_LEAF_1) expect(layout.expandedLeafId).toBeNull() expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'daemon-pty' }) expect(layout.buffersByLeafId).toBeUndefined() expect(layout.scrollbackRefsByLeafId).toEqual({ [TEST_LEAF_1]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) }) const ref = layout.scrollbackRefsByLeafId?.[TEST_LEAF_1] expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('Current buffer') expect(layout.titlesByLeafId).toEqual({ [TEST_LEAF_1]: 'Current' }) expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') }) it('does not reuse prior UUID leaves by position when legacy leaf counts changed', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: TEST_LEAF_1 }, second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: 'pane:1' }, activeLeafId: 'pane:1', expandedLeafId: null } } }) const root = store.getWorkspaceSession().terminalLayoutsByTabId.tab1.root const leafId = root?.type === 'leaf' ? root.leafId : null if (leafId === null) { throw new Error('Expected normalized leaf') } expect(isTerminalLeafId(leafId)).toBe(true) expect(leafId).not.toBe(TEST_LEAF_1) expect(leafId).not.toBe(TEST_LEAF_2) }) it('does not restore cleared SSH bindings after a lease expired', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'expired' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: {} } } }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) it('does not let an expired lease for another tab suppress a matching pty id', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab-expired', leafId: TEST_LEAF_EXPIRED, state: 'expired' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab-live', tabsByWorktree: { wt1: [ { id: 'tab-live', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { 'tab-live': { root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab-live', tabsByWorktree: { wt1: [ { id: 'tab-live', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { 'tab-live': { root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, ptyIdsByLeafId: {} } } }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ [TEST_LEAF_LIVE]: 'remote-pty' }) }) it('does not let an expired lease for another SSH target suppress the same tab binding', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'repo-live', connectionId: 'ssh-live' })) store.upsertSshRemotePtyLease({ targetId: 'ssh-expired', ptyId: 'remote-pty', worktreeId: 'repo-live::/wt', tabId: 'tab-live', leafId: TEST_LEAF_LIVE, state: 'expired' }) store.upsertSshRemotePtyLease({ targetId: 'ssh-live', ptyId: 'remote-pty', worktreeId: 'repo-live::/wt', tabId: 'tab-live', leafId: TEST_LEAF_LIVE, state: 'detached' }) store.setWorkspaceSession({ activeRepoId: 'repo-live', activeWorktreeId: 'repo-live::/wt', activeTabId: 'tab-live', tabsByWorktree: { 'repo-live::/wt': [ { id: 'tab-live', worktreeId: 'repo-live::/wt', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { 'tab-live': { root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } } } }) store.setWorkspaceSession({ activeRepoId: 'repo-live', activeWorktreeId: 'repo-live::/wt', activeTabId: 'tab-live', tabsByWorktree: { 'repo-live::/wt': [ { id: 'tab-live', worktreeId: 'repo-live::/wt', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { 'tab-live': { root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, activeLeafId: TEST_LEAF_LIVE, expandedLeafId: null, ptyIdsByLeafId: {} } } }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree['repo-live::/wt'][0].ptyId).toBe('remote-pty') expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ [TEST_LEAF_LIVE]: 'remote-pty' }) }) it('does not treat contextless expired leases as wildcards for contextual bindings', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', state: 'expired' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: {} } } }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'remote-pty' }) }) it('does not treat layout-level leases missing worktree context as contextual matches', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'expired' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: null } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: {} } } }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'remote-pty' }) }) it('merges missing prior layout bindings into partial renderer snapshots', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty-1', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'detached' }) store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty-2', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, state: 'detached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: TEST_LEAF_1 }, second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, activeLeafId: TEST_LEAF_2, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1', [TEST_LEAF_2]: 'remote-pty-2' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: TEST_LEAF_1 }, second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } } } }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'remote-pty-1', [TEST_LEAF_2]: 'remote-pty-2' }) }) it('does not restore layout bindings for leaves removed from the incoming layout', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty-1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'detached' }) store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty-2', tabId: 'tab1', leafId: TEST_LEAF_2, state: 'detached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: TEST_LEAF_1 }, second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, activeLeafId: TEST_LEAF_2, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1', [TEST_LEAF_2]: 'remote-pty-2' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } } } }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'remote-pty-1' }) }) it('does not restore missing layout bindings without a live SSH lease', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: TEST_LEAF_1 }, second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, activeLeafId: TEST_LEAF_2, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty-1', [TEST_LEAF_2]: 'local-pty-2' } } } }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'local-pty-1' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'split', direction: 'horizontal', first: { type: 'leaf', leafId: TEST_LEAF_1 }, second: { type: 'leaf', leafId: TEST_LEAF_2 }, ratio: 0.5 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty-1' } } } }) expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'local-pty-1' }) }) it('clears workspace bindings before removing SSH remote PTY leases for a target', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'detached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) store.removeSshRemotePtyLeases('ssh-1') const session = store.getWorkspaceSession() expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) it('clears workspace bindings when marking all SSH remote PTY leases for a target terminated', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'attached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'ssh:ssh-1@@remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) store.markSshRemotePtyLeases('ssh-1', 'terminated') const session = store.getWorkspaceSession() expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated' }) ]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) it('matches scoped SSH workspace bindings against raw relay leases', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'detached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'ssh:ssh-1@@remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) store.removeSshRemotePtyLeases('ssh-1') const session = store.getWorkspaceSession() expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) it('stores scoped SSH remote PTY leases as raw relay ids', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'ssh:ssh-1@@remote-pty', state: 'attached' }) expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ expect.objectContaining({ targetId: 'ssh-1', ptyId: 'remote-pty', state: 'attached' }) ]) }) it('rejects mismatched scoped SSH remote PTY lease ids on write paths', async () => { const store = await createStore() expect(() => store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'ssh:ssh-2@@remote-pty', state: 'attached' }) ).toThrow('belongs to SSH connection "ssh-2"') }) it('updates SSH remote PTY leases when callers pass scoped app ids', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', state: 'attached' }) store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated' }) ]) }) it('clears workspace bindings when marking an SSH remote PTY lease expired', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'attached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'ssh:ssh-1@@remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'expired') const session = store.getWorkspaceSession() expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ expect.objectContaining({ ptyId: 'remote-pty', state: 'expired' }) ]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) it('removes SSH remote PTY leases when callers pass scoped app ids', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, state: 'detached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'ssh:ssh-1@@remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) store.removeSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty') const session = store.getWorkspaceSession() expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) it('clears workspace bindings before removing contextless SSH remote PTY leases', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', state: 'detached' }) store.setWorkspaceSession({ activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', tabsByWorktree: { wt1: [ { id: 'tab1', worktreeId: 'wt1', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId: 'remote-pty' } ] }, terminalLayoutsByTabId: { tab1: { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } } } }) store.removeSshRemotePtyLeases('ssh-1') const session = store.getWorkspaceSession() expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) it('does not revive expired leases when marking a target detached', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'live-pty', state: 'attached' }) store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'expired-pty', state: 'expired' }) store.markSshRemotePtyLeases('ssh-1', 'detached') expect(store.getSshRemotePtyLeases('ssh-1')).toEqual( expect.arrayContaining([ expect.objectContaining({ ptyId: 'live-pty', state: 'detached' }), expect.objectContaining({ ptyId: 'expired-pty', state: 'expired' }) ]) ) }) // ── getAllWorktreeMeta ───────────────────────────────────────────── it('getAllWorktreeMeta returns all entries', async () => { const store = await createStore() store.setWorktreeMeta('a', { displayName: 'A' }) store.setWorktreeMeta('b', { displayName: 'B' }) const all = store.getAllWorktreeMeta() expect(Object.keys(all)).toHaveLength(2) expect(all['a'].displayName).toBe('A') expect(all['b'].displayName).toBe('B') }) // ── removeWorktreeMeta ───────────────────────────────────────────── it('removeWorktreeMeta deletes a single entry', async () => { const store = await createStore() store.setWorktreeMeta('a', { displayName: 'A' }) store.setWorktreeMeta('b', { displayName: 'B' }) store.removeWorktreeMeta('a') expect(store.getWorktreeMeta('a')).toBeUndefined() expect(store.getWorktreeMeta('b')).toBeDefined() }) it('stores and removes worktree lineage independently from metadata', async () => { const store = await createStore() const lineage = makeWorktreeLineage() store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) store.setWorktreeLineage(lineage.worktreeId, lineage) expect(store.getWorktreeLineage(lineage.worktreeId)).toEqual(lineage) expect(store.getAllWorktreeLineage()).toEqual({ [lineage.worktreeId]: lineage }) store.removeWorktreeLineage(lineage.worktreeId) expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() expect(store.getWorktreeMeta(lineage.worktreeId)).toBeDefined() }) it('removeWorktreeMeta deletes that worktree lineage entry', async () => { const store = await createStore() const lineage = makeWorktreeLineage() store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) store.setWorktreeLineage(lineage.worktreeId, lineage) store.removeWorktreeMeta(lineage.worktreeId) expect(store.getWorktreeMeta(lineage.worktreeId)).toBeUndefined() expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() }) it('stores workspace lineage and removes it with the child worktree metadata', async () => { const store = await createStore() const lineage = makeWorkspaceLineage() store.setWorktreeMeta('r1::/path/child', { displayName: 'child' }) store.setWorkspaceLineage(lineage) expect(store.getWorkspaceLineage(lineage.childWorkspaceKey)).toEqual(lineage) expect(store.getAllWorkspaceLineage()).toEqual({ [lineage.childWorkspaceKey]: lineage }) store.removeWorktreeMeta('r1::/path/child') expect(store.getWorkspaceLineage(lineage.childWorkspaceKey)).toBeUndefined() }) it('removeFolderWorkspace deletes child workspace lineage for that folder parent', async () => { const store = await createStore() const group = store.createProjectGroup({ name: 'Platform', parentPath: '/workspace/platform', createdFrom: 'folder-scan' }) const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Folder parent' }) const folderLineage = makeWorkspaceLineage({ parentWorkspaceKey: folderWorkspaceKey(workspace.id) }) const unrelatedLineage = makeWorkspaceLineage({ childWorkspaceKey: worktreeWorkspaceKey('r2::/other-child'), parentWorkspaceKey: folderWorkspaceKey('other-folder') }) store.setWorkspaceLineage(folderLineage) store.setWorkspaceLineage(unrelatedLineage) store.removeFolderWorkspace(workspace.id) expect(store.getWorkspaceLineage(folderLineage.childWorkspaceKey)).toBeUndefined() expect(store.getWorkspaceLineage(unrelatedLineage.childWorkspaceKey)).toEqual(unrelatedLineage) }) // ── Live Claude PTY session ids (STA-1246) ───────────────────────── describe('mobileClientTabSelectionsByDeviceId', () => { it('persists device tab selections across reloads and drops malformed payloads', async () => { const store = await createStore() store.setMobileClientTabSelections({ 'device-a': { 'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} } } }) store.flush() const reloaded = await createStore() expect(reloaded.getMobileClientTabSelections()['device-a']?.['repo-1::/tmp/wt']).toEqual({ activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }) writeDataFile({ mobileClientTabSelectionsByDeviceId: { 'device-a': 'corrupt' } }) const corrupted = await createStore() expect(corrupted.getMobileClientTabSelections()).toEqual({}) }) it('prunes selections for a removed repo worktree', async () => { const store = await createStore() store.addRepo(makeRepo()) store.setMobileClientTabSelections({ 'device-a': { 'r1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }, 'other-repo::/tmp/wt': { activeTabId: 'tab-2', activeGroupId: null, activeTabIdByGroupId: {} } } }) store.removeProject('r1') store.flush() expect(store.getMobileClientTabSelections()['device-a']).toEqual({ 'other-repo::/tmp/wt': { activeTabId: 'tab-2', activeGroupId: null, activeTabIdByGroupId: {} } }) const reloaded = await createStore() expect(reloaded.getMobileClientTabSelections()['device-a']).toEqual({ 'other-repo::/tmp/wt': { activeTabId: 'tab-2', activeGroupId: null, activeTabIdByGroupId: {} } }) }) it('prunes selections when a folder workspace is removed directly or with its group', async () => { const store = await createStore() const directGroup = store.createProjectGroup({ name: 'Direct', parentPath: '/tmp/direct', createdFrom: 'manual' }) const directWorkspace = store.createFolderWorkspace({ projectGroupId: directGroup.id, name: 'Direct workspace' }) const cascadeGroup = store.createProjectGroup({ name: 'Cascade', parentPath: '/tmp/cascade', createdFrom: 'manual' }) const cascadeWorkspace = store.createFolderWorkspace({ projectGroupId: cascadeGroup.id, name: 'Cascade workspace' }) store.setMobileClientTabSelections({ 'device-a': { [folderWorkspaceKey(directWorkspace.id)]: { activeTabId: 'tab-direct', activeGroupId: null, activeTabIdByGroupId: {} }, [folderWorkspaceKey(cascadeWorkspace.id)]: { activeTabId: 'tab-cascade', activeGroupId: null, activeTabIdByGroupId: {} } } }) store.removeFolderWorkspace(directWorkspace.id) store.deleteProjectGroup(cascadeGroup.id) store.flush() const reloaded = await createStore() expect(reloaded.getMobileClientTabSelections()).toEqual({}) }) }) describe('claudeLivePtySessionIds', () => { it('persists added ids across reloads and removes them durably', async () => { const store = await createStore() store.addClaudeLivePtySessionId('claude-session-1') store.addClaudeLivePtySessionId('claude-session-2') store.addClaudeLivePtySessionId('claude-session-1') expect(store.getClaudeLivePtySessionIds()).toEqual(['claude-session-1', 'claude-session-2']) const reloaded = await createStore() expect(reloaded.getClaudeLivePtySessionIds()).toEqual([ 'claude-session-1', 'claude-session-2' ]) reloaded.removeClaudeLivePtySessionId('claude-session-1') reloaded.flush() const reloadedAgain = await createStore() expect(reloadedAgain.getClaudeLivePtySessionIds()).toEqual(['claude-session-2']) }) it('drops malformed persisted entries on load', async () => { writeDataFile({ schemaVersion: 1, claudeLivePtySessionIds: ['valid-id', '', 42, null, 'valid-id', 'x'.repeat(513)] }) const store = await createStore() expect(store.getClaudeLivePtySessionIds()).toEqual(['valid-id']) }) it('keeps the newest ids when an oversized persisted list is loaded', async () => { writeDataFile({ schemaVersion: 1, claudeLivePtySessionIds: Array.from({ length: 205 }, (_, index) => `claude-${index}`) }) const store = await createStore() const ids = store.getClaudeLivePtySessionIds() expect(ids).toHaveLength(200) expect(ids[0]).toBe('claude-5') expect(ids[199]).toBe('claude-204') }) it('caps the persisted id list', async () => { const store = await createStore() for (let index = 0; index < 205; index += 1) { store.addClaudeLivePtySessionId(`claude-session-${index}`) } const ids = store.getClaudeLivePtySessionIds() expect(ids).toHaveLength(200) expect(ids[0]).toBe('claude-session-5') expect(ids[199]).toBe('claude-session-204') }) }) // ── Rolling backups (issue #1158) ────────────────────────────────── describe('rolling backups', () => { function backupFile(index: number): string { return `${dataFile()}.bak.${index}` } function readBackup(index: number): { repos: Repo[] } { return JSON.parse(readFileSync(backupFile(index), 'utf-8')) } function advanceMockedTime(advanceFn: () => void, ms: number): void { vi.setSystemTime(new Date(Date.now() + ms)) advanceFn() } it('snapshots the just-written file to .bak.0 on the very first write', async () => { const s = await createStore() s.addRepo(makeRepo()) s.flush() expect(existsSync(dataFile())).toBe(true) expect(existsSync(backupFile(0))).toBe(true) expect(readBackup(0).repos.map((r) => r.id)).toEqual(['r1']) }) it('rotates older .bak.0 to .bak.1 when the interval elapses', async () => { vi.useFakeTimers() try { const first = await createStore() first.addRepo(makeRepo({ id: 'r1' })) first.flush() expect(readBackup(0).repos.map((r) => r.id)).toEqual(['r1']) vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) const second = await createStore() second.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) second.flush() const current = readDataFile() as { repos: Repo[] } expect(current.repos.map((r) => r.id).sort()).toEqual(['r1', 'r2']) expect( readBackup(0) .repos.map((r) => r.id) .sort() ).toEqual(['r1', 'r2']) expect(readBackup(1).repos.map((r) => r.id)).toEqual(['r1']) } finally { vi.useRealTimers() } }) it('keeps at most 5 rotating backups', async () => { vi.useFakeTimers() try { writeDataFile({ schemaVersion: 1, repos: [makeRepo({ id: 'seed' })], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) for (let i = 0; i < 6; i++) { vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) const s = await createStore() s.addRepo(makeRepo({ id: `gen-${i}`, path: `/gen-${i}` })) s.flush() } for (let i = 0; i < 5; i++) { expect(existsSync(backupFile(i))).toBe(true) } expect(existsSync(backupFile(5))).toBe(false) } finally { vi.useRealTimers() } }) it('does not rotate more than once per hour', async () => { vi.useFakeTimers() try { writeDataFile({ schemaVersion: 1, repos: [makeRepo({ id: 'seed' })], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() store.addRepo(makeRepo({ id: 'after-seed' })) store.flush() const bak0After1 = readBackup(0) expect(bak0After1.repos.map((r) => r.id).sort()).toEqual(['after-seed', 'seed']) advanceMockedTime( () => { store.addRepo(makeRepo({ id: 'within-hour', path: '/within' })) store.flush() }, 5 * 60 * 1000 ) const bak0After2 = readBackup(0) expect(bak0After2.repos.map((r) => r.id).sort()).toEqual(['after-seed', 'seed']) } finally { vi.useRealTimers() } }) it('does not rotate on the async write path within the 1-hour window', async () => { vi.useFakeTimers() try { writeDataFile({ schemaVersion: 1, repos: [makeRepo({ id: 'seed' })], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() store.addRepo(makeRepo({ id: 'first-async' })) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const bak0AfterFirst = readBackup(0) expect(bak0AfterFirst.repos.map((r) => r.id).sort()).toEqual(['first-async', 'seed']) vi.setSystemTime(new Date(Date.now() + 5 * 60 * 1000)) store.addRepo(makeRepo({ id: 'within-hour-async', path: '/within-async' })) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const bak0AfterSecond = readBackup(0) expect(bak0AfterSecond.repos.map((r) => r.id).sort()).toEqual(['first-async', 'seed']) } finally { vi.useRealTimers() } }) it('rotates on the async write path after the 1-hour window elapses', async () => { vi.useFakeTimers() try { writeDataFile({ schemaVersion: 1, repos: [makeRepo({ id: 'seed' })], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() store.addRepo(makeRepo({ id: 'first-async' })) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() expect( readBackup(0) .repos.map((r) => r.id) .sort() ).toEqual(['first-async', 'seed']) vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) store.addRepo(makeRepo({ id: 'after-hour-async', path: '/after-async' })) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() expect( readBackup(0) .repos.map((r) => r.id) .sort() ).toEqual(['after-hour-async', 'first-async', 'seed']) expect(existsSync(backupFile(1))).toBe(true) expect( readBackup(1) .repos.map((r) => r.id) .sort() ).toEqual(['first-async', 'seed']) } finally { vi.useRealTimers() } }) function writeBackup(index: number, data: unknown): void { mkdirSync(testState.dir, { recursive: true }) writeFileSync(backupFile(index), JSON.stringify(data, null, 2), 'utf-8') } it('recovers from .bak.0 when the primary file is corrupt', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{corrupt-json', 'utf-8') writeBackup(0, { schemaVersion: 1, repos: [makeRepo({ id: 'recovered' })], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getRepos().map((r) => r.id)).toEqual(['recovered']) }) it('falls through to .bak.1 when both primary and .bak.0 are corrupt', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{corrupt-json', 'utf-8') writeFileSync(backupFile(0), '{{also-corrupt', 'utf-8') writeBackup(1, { schemaVersion: 1, repos: [makeRepo({ id: 'from-bak1' })], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getRepos().map((r) => r.id)).toEqual(['from-bak1']) }) it('falls back to defaults only when every backup is also unusable', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{corrupt', 'utf-8') for (let i = 0; i < 5; i++) { writeFileSync(backupFile(i), `{{slot-${i}-corrupt`, 'utf-8') } const store = await createStore() expect(store.getRepos()).toEqual([]) }) it('uses .bak.0 even when primary file is missing entirely', async () => { mkdirSync(testState.dir, { recursive: true }) writeBackup(0, { schemaVersion: 1, repos: [makeRepo({ id: 'rescued' })], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getRepos().map((r) => r.id)).toEqual(['rescued']) }) it('still recovers repos/worktrees from a backup with corrupt workspaceSession', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{corrupt', 'utf-8') writeBackup(0, { schemaVersion: 1, repos: [makeRepo({ id: 'survives' })], worktreeMeta: {}, settings: { theme: 'dark' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 12345 } }) const store = await createStore() expect(store.getRepos().map((r) => r.id)).toEqual(['survives']) expect(store.getSettings().theme).toBe('dark') }) }) // ── Concurrent write serialization (issue #1158) ─────────────────── describe('concurrent write serialization', () => { it('chains debounced writes via pendingWrite so they run sequentially', async () => { vi.useFakeTimers() try { const store = await createStore() store.addRepo(makeRepo({ id: 'first' })) vi.advanceTimersByTime(1000) store.addRepo(makeRepo({ id: 'second', path: '/second' })) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { repos: Repo[] } expect(persisted.repos.map((r) => r.id).sort()).toEqual(['first', 'second']) } finally { vi.useRealTimers() } }) }) // ── Telemetry cohort migration ───────────────────────────────────── // Why: keys on `existsSync(dataFile)`, not the new `telemetry` field, so pre-telemetry installs aren't misclassified as fresh and flipped default-on. it('classifies a truly fresh install as new-user cohort (file absent → optedIn=true)', async () => { // No data file written — truly fresh install of the telemetry release. const store = await createStore() const t = store.getSettings().telemetry expect(t).toBeDefined() expect(t!.existedBeforeTelemetryRelease).toBe(false) expect(t!.optedIn).toBe(true) expect(t!.installId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ ) }) it('classifies a pre-existing install as existing-user cohort (file present → optedIn=null)', async () => { // A pre-telemetry data file exists on disk with no telemetry block. writeDataFile({ schemaVersion: 1, repos: [makeRepo()], worktreeMeta: {}, settings: { theme: 'dark' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() const t = store.getSettings().telemetry expect(t).toBeDefined() expect(t!.existedBeforeTelemetryRelease).toBe(true) expect(t!.optedIn).toBeNull() expect(t!.installId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ ) // Sibling migrations still run alongside the telemetry migration. expect(store.getSettings().theme).toBe('dark') }) it('still classifies as existing-user cohort when the data file is corrupt', async () => { // Load-bearing: the corrupt-file catch path keeps `fileExistedOnLoad` true so a corrupted install isn't silently opted in as fresh. mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{corrupt json', 'utf-8') const store = await createStore() const t = store.getSettings().telemetry expect(t).toBeDefined() expect(t!.existedBeforeTelemetryRelease).toBe(true) expect(t!.optedIn).toBeNull() expect(t!.installId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ ) expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves an already-migrated telemetry block on subsequent launches', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { telemetry: { optedIn: true, installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', existedBeforeTelemetryRelease: false } }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().telemetry).toEqual({ optedIn: true, installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', existedBeforeTelemetryRelease: false }) }) }) describe('Store.migrateTabSwitchKeybindings', () => { // Freezes the tab-switch cohort on first load, keying on `fileExistedOnLoad` (not field presence) so the verdict survives later launches. beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) }) it('marks a truly fresh install done so it adopts the new defaults', async () => { const store = await createStore() expect(store.getSettings().tabSwitchKeybindingSeed).toBe('done') }) it('marks a pre-existing install pending so the legacy chords get seeded', async () => { writeDataFile({ schemaVersion: 1, repos: [makeRepo()], worktreeMeta: {}, settings: { theme: 'dark' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() expect(store.getSettings().tabSwitchKeybindingSeed).toBe('pending') expect(store.getSettings().theme).toBe('dark') }) it('treats a corrupt data file as a pre-existing install', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{corrupt json', 'utf-8') const store = await createStore() expect(store.getSettings().tabSwitchKeybindingSeed).toBe('pending') }) it('preserves an already-frozen cohort on subsequent launches', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: { tabSwitchKeybindingSeed: 'done' }, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() // Existing file, cohort already 'done' — must not flip to 'pending' just because the file exists. expect(store.getSettings().tabSwitchKeybindingSeed).toBe('done') }) }) describe('Store.migrateWorktreeIdentity', () => { const OLD = 'repo1::/ws/cunner' const NEW = 'repo1::/ws/worktree-creation-spinner' const OLD_WORKSPACE_KEY = worktreeWorkspaceKey(OLD) const NEW_WORKSPACE_KEY = worktreeWorkspaceKey(NEW) beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) }) it('moves meta, lineage, tabs, active pointers, and records the prior id', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner', linkedIssue: 42 }) store.setWorktreeLineage(OLD, makeWorktreeLineage({ worktreeId: OLD })) store.setWorkspaceLineage( makeWorkspaceLineage({ childWorkspaceKey: OLD_WORKSPACE_KEY, parentWorkspaceKey: folderWorkspaceKey('folder-parent') }) ) store.setWorkspaceLineage( makeWorkspaceLineage({ childWorkspaceKey: worktreeWorkspaceKey('repo1::/ws/child'), parentWorkspaceKey: OLD_WORKSPACE_KEY }) ) store.setWorkspaceSession({ activeRepoId: 'repo1', activeWorkspaceKey: OLD_WORKSPACE_KEY, activeWorktreeId: OLD, activeTabId: 'tab1', tabsByWorktree: { [OLD]: [makeTerminalTab({ id: 'tab1', worktreeId: OLD })] }, activeWorktreeIdsOnShutdown: [OLD], openFilesByWorktree: { [OLD]: [ { filePath: '/ws/cunner/a.ts', relativePath: 'a.ts', worktreeId: OLD, language: 'ts' } ] }, activeFileIdByWorktree: { [OLD]: '/ws/cunner/a.ts' }, browserTabsByWorktree: { [OLD]: [{ id: 'browser1', worktreeId: OLD, title: 'Browser', url: 'about:blank' }] }, browserPagesByWorkspace: { browser1: [{ id: 'page1', workspaceId: 'browser1', worktreeId: OLD }] }, activeBrowserTabIdByWorktree: { [OLD]: 'browser1' }, activeTabTypeByWorktree: { [OLD]: 'browser' }, activeTabIdByWorktree: { [OLD]: 'tab1' }, unifiedTabs: { [OLD]: [{ id: 'unified1', worktreeId: OLD }] }, tabGroups: { [OLD]: [{ id: 'group1', worktreeId: OLD, activeTabId: 'unified1', tabOrder: ['unified1'] }] }, tabGroupLayouts: { [OLD]: { type: 'leaf', groupId: 'group1' } }, activeGroupIdByWorktree: { [OLD]: 'group1' }, lastVisitedAtByWorktreeId: { [OLD]: 123 }, defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true }, terminalTopologyRevisionByRepoId: { repo1: 4 }, sleepingAgentSessionsByPaneKey: { 'tab1:leaf': { paneKey: 'tab1:leaf', tabId: 'tab1', worktreeId: OLD, agent: 'codex', providerSession: { key: 'session_id', id: 'session-1' }, prompt: 'Do work', state: 'done', capturedAt: 1, updatedAt: 1 } }, terminalLayoutsByTabId: {} } as unknown as WorkspaceSessionState) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), activeRepoId: 'repo1', activeWorkspaceKey: OLD_WORKSPACE_KEY, activeWorktreeId: OLD, tabsByWorktree: { [OLD]: [makeTerminalTab({ id: 'host-tab', worktreeId: OLD })] }, terminalLayoutsByTabId: {}, terminalTopologyRevisionByRepoId: { repo1: 8 }, terminalSurfaceTombstonesByPaneKey: { 'host-tab:leaf': { worktreeId: OLD, parentTabId: 'host-tab', leafId: 'leaf', ptyId: 'host-pty', incarnationId: 'host-incarnation', retiredAt: 1 } } }, 'runtime:env-a' ) store.migrateWorktreeIdentity(OLD, NEW) expect(store.getWorktreeMeta(OLD)).toBeUndefined() const meta = store.getWorktreeMeta(NEW) expect(meta?.displayName).toBe('Cunner') expect(meta?.linkedIssue).toBe(42) expect(meta?.priorWorktreeIds).toEqual([OLD]) expect(store.getWorktreeLineage(OLD)).toBeUndefined() expect(store.getWorktreeLineage(NEW)?.worktreeId).toBe(NEW) expect(store.getWorkspaceLineage(OLD_WORKSPACE_KEY)).toBeUndefined() expect(store.getWorkspaceLineage(NEW_WORKSPACE_KEY)?.childWorkspaceKey).toBe(NEW_WORKSPACE_KEY) expect( store.getWorkspaceLineage(worktreeWorkspaceKey('repo1::/ws/child'))?.parentWorkspaceKey ).toBe(NEW_WORKSPACE_KEY) // The live session's tab keeps its frozen ptyId but now belongs to the new id. expect(store.getWorktreeIdForTab('tab1')).toBe(NEW) const session = store.getWorkspaceSession() expect(session.tabsByWorktree[OLD]).toBeUndefined() expect(session.tabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW) expect(session.activeWorkspaceKey).toBe(NEW_WORKSPACE_KEY) expect(session.activeWorktreeIdsOnShutdown).toEqual([NEW]) expect(session.openFilesByWorktree?.[OLD]).toBeUndefined() expect(session.openFilesByWorktree?.[NEW]?.[0]?.worktreeId).toBe(NEW) expect(session.activeFileIdByWorktree?.[NEW]).toBe('/ws/cunner/a.ts') expect(session.browserTabsByWorktree?.[OLD]).toBeUndefined() expect(session.browserTabsByWorktree?.[NEW]?.[0]?.worktreeId).toBe(NEW) expect(session.browserPagesByWorkspace?.browser1?.[0]?.worktreeId).toBe(NEW) expect(session.activeBrowserTabIdByWorktree?.[NEW]).toBe('browser1') expect(session.activeTabTypeByWorktree?.[NEW]).toBe('browser') expect(session.activeWorktreeId).toBe(NEW) expect(session.activeTabIdByWorktree?.[NEW]).toBe('tab1') expect(session.unifiedTabs?.[NEW]?.[0]?.worktreeId).toBe(NEW) expect(session.tabGroups?.[NEW]?.[0]?.worktreeId).toBe(NEW) expect(session.tabGroupLayouts?.[NEW]).toEqual({ type: 'leaf', groupId: 'group1' }) expect(session.activeGroupIdByWorktree?.[NEW]).toBe('group1') expect(session.lastVisitedAtByWorktreeId?.[NEW]).toBe(123) expect(session.defaultTerminalTabsAppliedByWorktreeId?.[NEW]).toBe(true) expect(session.terminalTopologyRevisionByRepoId?.repo1).toBe(4) expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf']?.worktreeId).toBe(NEW) const hostSession = store.getWorkspaceSession('runtime:env-a') expect(hostSession.tabsByWorktree[OLD]).toBeUndefined() expect(hostSession.tabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW) expect(hostSession.activeWorkspaceKey).toBe(NEW_WORKSPACE_KEY) expect(hostSession.terminalTopologyRevisionByRepoId?.repo1).toBe(9) expect(hostSession.terminalSurfaceTombstonesByPaneKey).toEqual({}) }) it('rewrites parentWorktreeId back-references in other lineage entries', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) const CHILD = 'repo1::/ws/child' store.setWorktreeLineage( CHILD, makeWorktreeLineage({ worktreeId: CHILD, parentWorktreeId: OLD }) ) store.migrateWorktreeIdentity(OLD, NEW) expect(store.getWorktreeLineage(CHILD)?.parentWorktreeId).toBe(NEW) }) it('moves persisted mobile selections across reloads', async () => { const store = await createStore() store.setMobileClientTabSelections({ 'device-a': { [OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } } }) store.migrateWorktreeIdentity(OLD, NEW) store.flush() expect(store.getMobileClientTabSelections()['device-a']?.[OLD]).toBeUndefined() const reloaded = await createStore() expect(reloaded.getMobileClientTabSelections()['device-a']?.[NEW]?.activeTabId).toBe('tab-1') }) it('accumulates prior ids across chained renames', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) store.migrateWorktreeIdentity(OLD, NEW) const NEWER = 'repo1::/ws/final-name' store.migrateWorktreeIdentity(NEW, NEWER) expect(store.getWorktreeMeta(NEWER)?.priorWorktreeIds).toEqual([OLD, NEW]) }) it('is a no-op when the ids match', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) store.migrateWorktreeIdentity(OLD, OLD) expect(store.getWorktreeMeta(OLD)?.priorWorktreeIds).toBeUndefined() }) }) describe('Store host-partitioned workspace sessions', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) }) const makeHostSession = (activeRepoId: string): WorkspaceSessionState => ({ ...getDefaultWorkspaceSession(), activeRepoId }) const makeBoundHostSession = (ptyId: string | null): WorkspaceSessionState => ({ ...getDefaultWorkspaceSession(), activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::/worktree', activeTabId: 'tab-1', tabsByWorktree: { 'repo-1::/worktree': [ { id: 'tab-1', worktreeId: 'repo-1::/worktree', title: 'Terminal', customTitle: null, color: null, sortOrder: 0, createdAt: 1, ptyId } ] }, terminalLayoutsByTabId: { 'tab-1': { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, ptyIdsByLeafId: ptyId ? { [TEST_LEAF_1]: ptyId } : {} } } }) it('migrates a legacy workspaceSession blob into the local partition', async () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('legacy-repo') }) const store = await createStore() // The legacy blob is the 'local' partition; an explicit/default hostId reads it. expect(store.getWorkspaceSession().activeRepoId).toBe('legacy-repo') expect(store.getWorkspaceSession('local').activeRepoId).toBe('legacy-repo') // No data was moved, so a downgrade still finds the legacy field intact. store.flush() const persisted = readDataFile() as { workspaceSession?: { activeRepoId?: string } } expect(persisted.workspaceSession?.activeRepoId).toBe('legacy-repo') }) it('is idempotent: re-loading already-partitioned state preserves all hosts', async () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), workspaceSessionsByHostId: { 'runtime:env-a': makeHostSession('runtime-repo'), 'ssh:host-b': makeHostSession('ssh-repo') } }) const readSessionPartitions = (): unknown => { const data = readDataFile() as { workspaceSession?: unknown workspaceSessionsByHostId?: unknown } return { workspaceSession: data.workspaceSession, workspaceSessionsByHostId: data.workspaceSessionsByHostId } } const first = await createStore() first.flush() const afterFirst = readSessionPartitions() const second = await createStore() second.flush() const afterSecond = readSessionPartitions() // Re-running the partition migration must not move or reshape any host. expect(afterSecond).toEqual(afterFirst) expect(second.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('runtime-repo') expect(second.getWorkspaceSession('ssh:host-b').activeRepoId).toBe('ssh-repo') expect(second.getWorkspaceSession('local').activeRepoId).toBe('local-repo') }) it('drops a stray "local" key in workspaceSessionsByHostId in favor of the legacy blob', async () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('canonical-local'), workspaceSessionsByHostId: { local: makeHostSession('shadow-local') } }) const store = await createStore() expect(store.getWorkspaceSession('local').activeRepoId).toBe('canonical-local') }) it('isolates writes: setting host A does not mutate host B or local', async () => { const store = await createStore() store.setWorkspaceSession(makeHostSession('repo-local'), 'local') store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') store.setWorkspaceSession(makeHostSession('repo-b'), 'runtime:env-b') expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b') // Overwriting host A leaves host B and local untouched. store.setWorkspaceSession(makeHostSession('repo-a2'), 'runtime:env-a') expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a2') expect(store.getWorkspaceSession('runtime:env-b').activeRepoId).toBe('repo-b') expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') }) it('patches a single host partition without touching the others', async () => { const store = await createStore() store.setWorkspaceSession(makeHostSession('repo-local'), 'local') store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') store.patchWorkspaceSession({ activeTabId: 'tab-a' }, 'runtime:env-a') expect(store.getWorkspaceSession('runtime:env-a').activeTabId).toBe('tab-a') expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') // Local was never given that tab id. expect(store.getWorkspaceSession('local').activeTabId).toBeNull() expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') }) it('preserves and enforces equal repo-id topology authority independently per host', async () => { const store = await createStore() const worktreeId = 'duplicate::/worktree' const staleTabs = { [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId, ptyId: 'stale-pty' })] } store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), terminalTopologyRevisionByRepoId: { duplicate: 2 } }, 'runtime:env-a' ) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'live-tab', worktreeId, ptyId: 'live-pty' })] }, terminalTopologyRevisionByRepoId: { duplicate: 7 } }, 'runtime:env-b' ) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), tabsByWorktree: staleTabs }, 'runtime:env-a' ) store.patchWorkspaceSession({ tabsByWorktree: staleTabs }, 'runtime:env-a') expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toEqual([]) expect( store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.duplicate ).toBe(2) expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]?.[0]?.id).toBe( 'live-tab' ) expect( store.getWorkspaceSession('runtime:env-b').terminalTopologyRevisionByRepoId?.duplicate ).toBe(7) }) it('persists an SSH PTY binding only in the SSH host partition', async () => { const store = await createStore() store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', tabId: 'tab-1', leafId: TEST_LEAF_1, ptyId: 'ssh:ssh-1@@remote-pty' }, 'ssh:ssh-1' ) expect( store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId ).toBe('ssh:ssh-1@@remote-pty') expect( store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId ).toBeNull() }) it('rolls back a failed SSH PTY binding flush in the SSH host partition', async () => { const store = await createStore() store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { throw new Error('disk unavailable') }) expect(() => store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', tabId: 'tab-1', leafId: TEST_LEAF_1, ptyId: 'ssh:ssh-1@@remote-pty' }, 'ssh:ssh-1' ) ).toThrow('disk unavailable') flush.mockRestore() expect( store.getWorkspaceSession('ssh:ssh-1').tabsByWorktree['repo-1::/worktree'][0]?.ptyId ).toBeNull() expect( store.getWorkspaceSession('local').tabsByWorktree['repo-1::/worktree'][0]?.ptyId ).toBeNull() }) it('clears expired SSH PTY bindings from the SSH partition and legacy local copy', async () => { const store = await createStore() const ptyId = 'ssh:ssh-1@@remote-pty' store.setWorkspaceSession(makeBoundHostSession(ptyId), 'local') store.setWorkspaceSession(makeBoundHostSession(ptyId), 'ssh:ssh-1') store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', worktreeId: 'repo-1::/worktree', tabId: 'tab-1', leafId: TEST_LEAF_1, state: 'attached' }) store.markSshRemotePtyLease('ssh-1', ptyId, 'expired') for (const hostId of ['local', 'ssh:ssh-1']) { const session = store.getWorkspaceSession(hostId) expect(session.tabsByWorktree['repo-1::/worktree'][0]?.ptyId).toBeNull() expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({}) } }) it('defaults an omitted hostId to the local partition', async () => { const store = await createStore() store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') // No hostId → local, which is still empty/default and unaffected by host A. store.setWorkspaceSession(makeHostSession('repo-local')) expect(store.getWorkspaceSession().activeRepoId).toBe('repo-local') expect(store.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') }) it('round-trips host partitions through disk', async () => { const store = await createStore() store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') store.flush() const reloaded = await createStore() expect(reloaded.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') }) it('drops a corrupt host partition to defaults without failing the others', async () => { writeDataFile({ schemaVersion: 1, workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. 'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 } } }) const store = await createStore() expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo') // Bad partition collapses to defaults rather than poisoning the map. expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() }) }) describe('Store native-chat tab viewMode persistence', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) }) // Why: tabs persisted before viewMode existed default to 'terminal' so older sessions stay backward-compatible. it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => { const WORKTREE = 'repo1::/worktree' writeDataFile({ schemaVersion: 1, repos: [makeRepo()], worktreeMeta: {}, settings: {}, ui: {}, githubCache: { pr: {}, issue: {} }, workspaceSession: { activeRepoId: 'r1', activeWorktreeId: WORKTREE, activeTabId: 'chat-tab', tabsByWorktree: {}, terminalLayoutsByTabId: {}, sleepingAgentSessionsByPaneKey: {}, unifiedTabs: { [WORKTREE]: [ { id: 'chat-tab', entityId: 'chat-tab', groupId: 'g1', worktreeId: WORKTREE, contentType: 'terminal', label: 'Agent', customLabel: null, color: null, sortOrder: 0, createdAt: 1, viewMode: 'chat' }, { // Legacy tab persisted before viewMode existed — no field at all. id: 'legacy-tab', entityId: 'legacy-tab', groupId: 'g1', worktreeId: WORKTREE, contentType: 'terminal', label: 'Legacy', customLabel: null, color: null, sortOrder: 1, createdAt: 2 } ] }, tabGroups: { [WORKTREE]: [ { id: 'g1', worktreeId: WORKTREE, activeTabId: 'chat-tab', tabOrder: ['chat-tab', 'legacy-tab'] } ] } } }) const store = await createStore() const restored = store.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? [] const chatTab = restored.find((tab) => tab.id === 'chat-tab') const legacyTab = restored.find((tab) => tab.id === 'legacy-tab') expect(chatTab?.viewMode).toBe('chat') // Missing on a legacy tab; renderer hydration treats absent as 'terminal'. expect(legacyTab?.viewMode).toBeUndefined() }) })