diff --git a/src/renderer/src/store/host-twin-isolation.test.ts b/src/renderer/src/store/host-twin-isolation.test.ts new file mode 100644 index 00000000000..b5ec0db49af --- /dev/null +++ b/src/renderer/src/store/host-twin-isolation.test.ts @@ -0,0 +1,259 @@ +/** + * Destructive and selection actions aimed at one twin must leave the other twin's state alone. + * The action table is declared by hand: adding a destroy, hide, link or select entry point means + * adding its row here. Cells listed in KNOWN_TWIN_LEAKS are today's failures (the Stage 1 worklist): + * they assert the leak still happens, so a fix turns them red until the entry is deleted. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../runtime/runtime-rpc-client' +import { + changedHostTwinState, + projectHostTwinState, + resetHostTwinModuleStores +} from './host-twin-state-projection.test-fixture' +import { + createHostTwinFixture, + TWIN_RUNTIME_ENV, + type HostTwinFixture, + type HostTwinKind, + type HostTwinSide +} from './host-twin.test-fixture' + +const appStore = vi.hoisted((): { current: HostTwinFixture['store'] | null } => ({ + current: null +})) + +// Why: the sleep flow reads the app singleton; route it to this test's store. +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!appStore.current) { + throw new Error('no twin fixture store bound') + } + return appStore.current.getState() + } + } +})) + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +const { sleepWorktreesReportingFailures } = await import('@/components/sidebar/sleep-worktree-flow') + +const ptyKill = vi.fn() + +function answerLikeHost(args: RuntimeEnvironmentCallRequest): unknown { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return compatible + } + const result = + args.method === 'terminal.sleep' + ? { + stopped: 1, + stoppedPtyIds: [], + livePtyIds: [], + postStopVerified: true + } + : {} + return { + id: args.method, + ok: true, + result, + _meta: { runtimeId: 'runtime-b' } + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + ptyKill.mockReset() + vi.stubGlobal('window', { + api: { + repos: { remove: vi.fn(), removeForHost: vi.fn() }, + folderWorkspaces: { delete: vi.fn().mockResolvedValue(true) }, + pty: { kill: ptyKill }, + gh: { enqueuePRRefresh: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { + call: vi.fn(answerLikeHost), + list: vi.fn().mockResolvedValue([{ id: TWIN_RUNTIME_ENV }]) + }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + }) +}) + +let current: HostTwinFixture | null = null + +afterEach(() => { + if (current) { + resetHostTwinModuleStores(current) + } + current = null + appStore.current = null + vi.unstubAllGlobals() +}) + +type TwinAction = { + id: string + /** The entry point under test; review this table when one is added or moved. */ + source: string + appliesTo: (kind: HostTwinKind) => boolean + run: (fixture: HostTwinFixture, target: HostTwinSide) => Promise + /** Proves the action reached its target, so an isolated no-op can't pass. */ + applied: (fixture: HostTwinFixture, target: HostTwinSide) => void +} + +const isFolderKind = (kind: HostTwinKind) => kind === 'folder local+ssh' +const settle = async (run: () => unknown) => { + try { + await run() + } catch { + // A refusal is an acceptable outcome; the isolation assertion is what matters. + } +} + +const TWIN_ACTIONS: readonly TwinAction[] = [ + { + id: 'remove project', + source: 'store/repos/repo-removal.ts removeProject', + appliesTo: (kind) => !isFolderKind(kind), + run: (f, target) => f.store.getState().removeProject(f.projectId, { hostId: target.hostId }), + applied: (f, target) => + expect(f.store.getState().repos.map((repo) => repo.executionHostId)).not.toContain( + target.hostId + ) + }, + { + id: 'delete folder workspace', + source: 'store/folder-workspaces/folder-workspace-mutations.ts deleteFolderWorkspace', + appliesTo: isFolderKind, + run: (f, target) => + f.store.getState().deleteFolderWorkspace(f.projectId, { executionHostId: target.hostId }), + applied: (f, target) => + expect( + (f.store.getState().folderWorkspaces ?? []).map((folder) => folder.executionHostId) + ).not.toContain(target.hostId) + }, + { + id: 'purge terminal state', + source: 'store/slices/worktrees.ts purgeWorktreeTerminalState', + appliesTo: () => true, + run: async (f, target) => + f.store + .getState() + .purgeWorktreeTerminalState([{ id: f.workspaceKey, hostId: target.hostId }]), + applied: (f, target) => + expect(f.store.getState().ptyIdsByTabId[target.terminalTabId]).toBeUndefined() + }, + { + id: 'kill terminals', + source: 'store/terminals/terminal-shutdown.ts shutdownWorktreeTerminals', + appliesTo: () => true, + // Why raw id: the entry point cannot name a host yet (Stage 1.3 HostWorktreeTarget). + run: (f) => settle(() => f.store.getState().shutdownWorktreeTerminals(f.workspaceKey)), + applied: (f, target) => + expect(f.store.getState().ptyIdsByTabId[target.terminalTabId] ?? []).toEqual([]) + }, + { + id: 'sleep', + source: 'components/sidebar/sleep-worktree-flow.ts sleepWorktreesReportingFailures', + appliesTo: () => true, + run: (f) => settle(() => sleepWorktreesReportingFailures([f.workspaceKey])), + applied: (f, target) => + expect(f.store.getState().ptyIdsByTabId[target.terminalTabId] ?? []).toEqual([]) + }, + { + id: 'select', + source: 'store/slices/worktrees.ts setActiveWorktree / setActiveFolderWorkspace', + appliesTo: () => true, + run: async (f, target) => + isFolderKind(f.kind) + ? f.store.getState().setActiveFolderWorkspace(f.projectId, target.hostId) + : f.store.getState().setActiveWorktree(f.workspaceKey, target.hostId), + applied: (f, target) => + expect(f.store.getState().activeWorkspaceExecutionHostId).toBe(target.hostId) + } +] + +const TWIN_KINDS: readonly HostTwinKind[] = ['local+ssh', 'local+runtime', 'folder local+ssh'] + +function everyCell(actionId: string, reason: string, kinds = TWIN_KINDS): [string, string][] { + return kinds.flatMap((kind) => + (['remote', 'local'] as const).map((target): [string, string] => [ + `${actionId} | ${kind} | ${target}`, + reason + ]) + ) +} + +/** ` | | ` → why the survivor is touched today (tracker Stage 1 worklist). */ +const KNOWN_TWIN_LEAKS: Readonly> = Object.fromEntries([ + ...everyCell( + 'purge terminal state', + 'the purge drops every bucket under the raw id; the target host only scopes visit recency' + ), + ...everyCell('kill terminals', 'takes a raw worktree id, so it stops both twins’ PTYs'), + ...everyCell('sleep', 'takes a raw worktree id, so it sleeps both twins'), + [ + 'remove project | local+runtime | local', + 'a sleeping-agent record with no host evidence is dropped instead of kept for the survivor' + ], + ...everyCell( + 'select', + 'activating a slept twin clears the shared raw-id sleep marker and re-arms both twins’ tabs' + ) +]) + +function cellId(action: TwinAction, kind: HostTwinKind, target: 'local' | 'remote'): string { + return `${action.id} | ${kind} | ${target}` +} + +describe('an action on one twin leaves the other twin alone', () => { + for (const kind of TWIN_KINDS) { + for (const action of TWIN_ACTIONS.filter((a) => a.appliesTo(kind))) { + for (const targetName of ['remote', 'local'] as const) { + const id = cellId(action, kind, targetName) + const knownLeak = KNOWN_TWIN_LEAKS[id] + it(knownLeak ? `${id} (known leak: ${knownLeak})` : id, async () => { + const fixture = createHostTwinFixture(kind) + current = fixture + appStore.current = fixture.store + const target = fixture[targetName] + const survivor = targetName === 'remote' ? fixture.local : fixture.remote + const before = projectHostTwinState(fixture, survivor) + + await action.run(fixture, target) + + const after = projectHostTwinState(fixture, survivor) + const changed = changedHostTwinState(before, after) + action.applied(fixture, target) + if (knownLeak) { + // Fixed? Delete this cell's KNOWN_TWIN_LEAKS entry. + expect(changed).not.toEqual([]) + return + } + expect(changed).toEqual([]) + if (survivor.hostId === 'local') { + expect(ptyKill).not.toHaveBeenCalledWith(survivor.ptyId) + } + }) + } + } + } +}) + +describe('the twin fixture', () => { + it('seeds two hosts under one workspace key', () => { + const fixture = createHostTwinFixture('local+ssh') + current = fixture + const hosts: ExecutionHostId[] = [fixture.local.hostId, fixture.remote.hostId] + expect(hosts).toEqual(['local', 'ssh:conn-b']) + expect(fixture.store.getState().tabsByWorktree[fixture.workspaceKey]).toHaveLength(3) + }) +}) diff --git a/src/renderer/src/store/host-twin-state-projection.test-fixture.ts b/src/renderer/src/store/host-twin-state-projection.test-fixture.ts new file mode 100644 index 00000000000..6144c7cfa75 --- /dev/null +++ b/src/renderer/src/store/host-twin-state-projection.test-fixture.ts @@ -0,0 +1,100 @@ +/** What one twin owns, read back out of a host-twin fixture's store and module stores. */ +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import type { AppState } from './types' +import { + readNativeChatComposerDraft, + updateNativeChatComposerDraft +} from '@/components/native-chat/native-chat-composer-draft-store' +import { forgetWorktreeSleepIntent, hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { + clearHugeRepoWarningDismissalsForTests, + hasDismissedHugeRepoWarning +} from '@/lib/source-control-huge-repo-warning-dismissals' +import type { HostTwinFixture, HostTwinSide } from './host-twin.test-fixture' + +export function resetHostTwinModuleStores(fixture: HostTwinFixture): void { + forgetWorktreeSleepIntent(fixture.workspaceKey) + clearHugeRepoWarningDismissalsForTests() + for (const s of [fixture.local, fixture.remote]) { + updateNativeChatComposerDraft(s.paneKey, { text: '' }, 'deferred') + } +} + +function pick( + record: Record | undefined, + keys: readonly string[] +): Record { + return Object.fromEntries( + keys.flatMap((key) => (record && key in record ? [[key, record[key]] as const] : [])) + ) +} + +/** + * Everything the fixture attributes to `owner`, plus the unattributable shared entries that must + * survive while a twin does. An action on the other twin must leave this unchanged. + */ +export function projectHostTwinState(fixture: HostTwinFixture, owner: HostTwinSide) { + const state: AppState = fixture.store.getState() + const key = fixture.workspaceKey + const tabIds = [owner.terminalTabId, fixture.dormantTabId] + const ownedIds = new Set([...tabIds, owner.fileId]) + return { + repos: (state.repos ?? []).filter((repo) => getRepoExecutionHostId(repo) === owner.hostId), + worktrees: (state.worktreesByRepo[fixture.projectId] ?? []).filter( + (worktree) => worktree.hostId === owner.hostId + ), + folderWorkspaces: (state.folderWorkspaces ?? []).filter( + (folder) => folder.executionHostId === owner.hostId + ), + projectGroups: (state.projectGroups ?? []).filter( + (group) => group.executionHostId === owner.hostId + ), + tabs: (state.tabsByWorktree[key] ?? []).filter((tab) => tabIds.includes(tab.id)), + ptyIds: pick(state.ptyIdsByTabId, tabIds), + layouts: pick(state.terminalLayoutsByTabId, tabIds), + unifiedTabs: (state.unifiedTabsByWorktree[key] ?? []).filter((tab) => ownedIds.has(tab.id)), + openFiles: state.openFiles.filter((file) => file.id === owner.fileId), + editorDrafts: pick(state.editorDrafts, [owner.fileId]), + sleepingAgentSessions: pick(state.sleepingAgentSessionsByPaneKey, [owner.paneKey]), + moduleStores: snapshotHostTwinModuleStores(fixture, owner) + } +} + +/** + * Module-level stores the purge reaches outside the zustand state. Registered by hand: a store + * added to `buildWorktreePurgeState`'s forgetters belongs here too. + */ +const HOST_TWIN_MODULE_STORES: readonly { + name: string + read: (fixture: HostTwinFixture, owner: HostTwinSide) => unknown +}[] = [ + { + name: 'worktree-sleep-intent', + read: (fixture) => hasWorktreeSleepIntent(fixture.workspaceKey) + }, + { + name: 'huge-repo-warning-dismissal', + read: (fixture) => hasDismissedHugeRepoWarning(fixture.hugeRepoWarningProbe) + }, + { + name: 'native-chat-composer-draft', + read: (_fixture, owner) => readNativeChatComposerDraft(owner.paneKey).text + } +] + +function snapshotHostTwinModuleStores( + fixture: HostTwinFixture, + owner: HostTwinSide +): Record { + return Object.fromEntries(HOST_TWIN_MODULE_STORES.map((s) => [s.name, s.read(fixture, owner)])) +} + +/** Which projected entries differ; compared by value, as persisted state would be. */ +export function changedHostTwinState( + before: Readonly>, + after: Readonly> +): string[] { + return Object.keys(before).filter( + (key) => JSON.stringify(after[key]) !== JSON.stringify(before[key]) + ) +} diff --git a/src/renderer/src/store/host-twin.test-fixture.ts b/src/renderer/src/store/host-twin.test-fixture.ts new file mode 100644 index 00000000000..bad4c633c74 --- /dev/null +++ b/src/renderer/src/store/host-twin.test-fixture.ts @@ -0,0 +1,267 @@ +/** + * Twins: one repo (or folder workspace) registered on two execution hosts. They share the repo + * id, the worktree id and every raw-id-keyed store bucket, so an action aimed at one twin can + * silently reach the other. The fixture seeds both twins with live shells, a dormant tab, dirty + * documents, drafts and sleeping-agent records; `host-twin-state-projection.test-fixture.ts` reads one host back. + */ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { + createTestStore, + makeOpenFile, + makeTab, + makeTabGroup, + makeUnifiedTab, + makeWorktree +} from './slices/store-test-helpers' +import { makeFolderWorkspace } from './slices/worktrees-slice-test-fixtures' +import { updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { + beginHugeRepoWarningProbe, + markHugeRepoWarningDismissed, + type HugeRepoWarningProbe +} from '@/lib/source-control-huge-repo-warning-dismissals' + +export type HostTwinKind = 'local+ssh' | 'local+runtime' | 'folder local+ssh' + +export const TWIN_SSH_TARGET = 'conn-b' +export const TWIN_RUNTIME_ENV = 'env-b' +const PROJECT_ID = 'twin-repo' +const FOLDER_ID = 'twin-folder' +const GROUP_ID = 'twin-group' +const TAB_GROUP_ID = 'twin-tab-group' +const LEAF = 'leaf-1' + +/** What one host owns in the fixture; everything else in a shared bucket is the other host's or shared. */ +export type HostTwinSide = { + hostId: ExecutionHostId + terminalTabId: string + ptyId: string + fileId: string + paneKey: string +} + +export type HostTwinFixture = { + kind: HostTwinKind + store: ReturnType + /** Repo id, or the folder workspace id for folder twins. */ + projectId: string + /** The key both twins' tabs live under: the worktree id or the folder workspace key. */ + workspaceKey: string + local: HostTwinSide + remote: HostTwinSide + /** A tab with no PTY: no evidence names its host, so it is kept while either twin survives. */ + dormantTabId: string + hugeRepoWarningProbe: HugeRepoWarningProbe +} + +function remoteHostFor(kind: HostTwinKind): ExecutionHostId { + return kind === 'local+runtime' ? `runtime:${TWIN_RUNTIME_ENV}` : `ssh:${TWIN_SSH_TARGET}` +} + +function remotePtyIdFor(kind: HostTwinKind): string { + return kind === 'local+runtime' + ? toRemoteRuntimePtyId('handle-b', TWIN_RUNTIME_ENV) + : toAppSshPtyId(TWIN_SSH_TARGET, 'pty-b') +} + +function side(hostId: ExecutionHostId, tag: string, ptyId: string, root: string): HostTwinSide { + const terminalTabId = `tab-${tag}` + return { + hostId, + terminalTabId, + ptyId, + fileId: `${root}/${tag}.ts`, + paneKey: `${terminalTabId}:${LEAF}` + } +} + +function repoRow(executionHostId: ExecutionHostId, root: string): Repo { + return { + id: PROJECT_ID, + path: root, + displayName: 'Twin', + badgeColor: '#000', + addedAt: 1, + executionHostId, + ...(executionHostId.startsWith('ssh:') ? { connectionId: TWIN_SSH_TARGET } : {}) + } +} + +function projectGroupRow(executionHostId: ExecutionHostId): ProjectGroup { + return { + id: GROUP_ID, + name: 'Twin group', + parentPath: '/work/twin', + executionHostId, + ...(executionHostId.startsWith('ssh:') ? { connectionId: TWIN_SSH_TARGET } : {}), + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +function sleepingRecord( + sideOf: HostTwinSide, + workspaceKey: string, + connectionId: string | null +): SleepingAgentSessionRecord { + return { + paneKey: sideOf.paneKey, + worktreeId: workspaceKey, + agent: 'claude', + providerSession: { key: 'session_id', id: sideOf.paneKey }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId + } +} + +export function createHostTwinFixture(kind: HostTwinKind): HostTwinFixture { + const store = createTestStore() + const root = '/work/twin' + const isFolder = kind === 'folder local+ssh' + const projectId = isFolder ? FOLDER_ID : PROJECT_ID + const workspaceKey = isFolder ? folderWorkspaceKey(FOLDER_ID) : `${PROJECT_ID}::${root}` + const remoteHostId = remoteHostFor(kind) + const local = side('local', 'local', 'pty-local', root) + const remote = side(remoteHostId, 'remote', remotePtyIdFor(kind), root) + const dormantTabId = 'tab-dormant' + const sides = [local, remote] + const tab = (id: string, ptyId: string | null) => makeTab({ id, worktreeId: workspaceKey, ptyId }) + const unified = ( + id: string, + executionHostId: ExecutionHostId, + contentType: 'terminal' | 'editor' + ) => + makeUnifiedTab({ + id, + worktreeId: workspaceKey, + groupId: TAB_GROUP_ID, + executionHostId, + contentType + }) + + store.setState({ + ...(isFolder + ? { + projectGroups: [projectGroupRow('local'), projectGroupRow(remoteHostId)], + folderWorkspaces: [ + makeFolderWorkspace({ + id: FOLDER_ID, + projectGroupId: GROUP_ID, + folderPath: root, + executionHostId: 'local' + }), + makeFolderWorkspace({ + id: FOLDER_ID, + projectGroupId: GROUP_ID, + folderPath: root, + executionHostId: remoteHostId, + connectionId: TWIN_SSH_TARGET + }) + ] + } + : { + repos: [repoRow('local', root), repoRow(remoteHostId, root)], + worktreesByRepo: { + [PROJECT_ID]: [ + makeWorktree({ + id: workspaceKey, + repoId: PROJECT_ID, + path: root, + hostId: 'local' + }), + makeWorktree({ + id: workspaceKey, + repoId: PROJECT_ID, + path: root, + hostId: remoteHostId + }) + ] + } + }), + tabsByWorktree: { + [workspaceKey]: [...sides.map((s) => tab(s.terminalTabId, s.ptyId)), tab(dormantTabId, null)] + }, + ptyIdsByTabId: Object.fromEntries(sides.map((s) => [s.terminalTabId, [s.ptyId]])), + terminalLayoutsByTabId: Object.fromEntries( + sides.map((s) => [ + s.terminalTabId, + { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: s.ptyId } + } + ]) + ), + unifiedTabsByWorktree: { + [workspaceKey]: [ + ...sides.flatMap((s) => [ + unified(s.terminalTabId, s.hostId, 'terminal'), + unified(s.fileId, s.hostId, 'editor') + ]), + makeUnifiedTab({ + id: dormantTabId, + worktreeId: workspaceKey, + groupId: TAB_GROUP_ID + }) + ] + }, + groupsByWorktree: { + [workspaceKey]: [ + makeTabGroup({ + id: TAB_GROUP_ID, + worktreeId: workspaceKey, + activeTabId: local.terminalTabId, + tabOrder: [...sides.flatMap((s) => [s.terminalTabId, s.fileId]), dormantTabId] + }) + ] + }, + openFiles: sides.map((s) => + makeOpenFile({ + id: s.fileId, + worktreeId: workspaceKey, + isDirty: true, + ...(s.hostId.startsWith('runtime:') ? { runtimeEnvironmentId: TWIN_RUNTIME_ENV } : {}) + }) + ), + editorDrafts: Object.fromEntries(sides.map((s) => [s.fileId, `unsaved ${s.hostId} edit`])), + sleepingAgentSessionsByPaneKey: Object.fromEntries( + sides.map((s) => [ + s.paneKey, + sleepingRecord(s, workspaceKey, s.hostId.startsWith('ssh:') ? TWIN_SSH_TARGET : null) + ]) + ) + }) + for (const s of sides) { + updateNativeChatComposerDraft(s.paneKey, { text: `${s.hostId} chat draft` }, 'deferred') + } + // Raw-id module stores: neither can say which twin wrote it, so each belongs to both. + markWorktreeSleepIntent(workspaceKey) + const hugeRepoWarningProbe = beginHugeRepoWarningProbe({ id: workspaceKey }) + markHugeRepoWarningDismissed(hugeRepoWarningProbe) + return { + kind, + store, + projectId, + workspaceKey, + local, + remote, + dormantTabId, + hugeRepoWarningProbe + } +}