diff --git a/docs/audits/acknowledged-tab-retirement/fixture.test.ts b/docs/audits/acknowledged-tab-retirement/fixture.test.ts index b7eff444c5d..4036c246734 100644 --- a/docs/audits/acknowledged-tab-retirement/fixture.test.ts +++ b/docs/audits/acknowledged-tab-retirement/fixture.test.ts @@ -13,7 +13,7 @@ import { buildWorkspaceSessionPayload } from '../../../src/renderer/src/lib/work import { buildHeadlessMobileSessionTerminalTabs } from '../../../src/main/runtime/mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from '../../../src/main/runtime/runtime-desktop-surface' import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' -import { advanceTerminalTopologyRevision } from '../../../src/main/runtime/workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../../../src/main/persistence/terminal-topology/terminal-topology-membership' import type { ExecutionHostId } from '../../../src/shared/execution-host' class AuditRuntime extends OrcaRuntimeService { diff --git a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts index 14ac4de0a12..5fce5868848 100644 --- a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts +++ b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts @@ -9,7 +9,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' -import { findTerminalTabIdForLeaf } from './runtime/workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from './persistence/terminal-topology/terminal-topology-membership' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts new file mode 100644 index 00000000000..27aa67c7d6e --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -0,0 +1,133 @@ +/** + * Ratchet: outside `persistence/terminal-topology/`, only the files listed here may reference a + * named layout writer. Blind to in-place mutation of the object `getWorkspaceSession` returns. + */ +import { resolve } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../../shared/source-scan/source-tree-scan' + +const MAIN_ROOT = resolve(__dirname, '../..') +const BOUNDARY_DIR = 'persistence/terminal-topology/' + +/** + * Files (relative to `src/main`) outside the boundary referencing each writer; each routing change + * deletes its own rows. Not listed: lifecycle writers (repo/worktree removal, identity rekey) and + * SSH lease marks that drop a dead PTY's binding; they delete or rekey layout, never choose it. + */ +const ALLOWED_REFERENCES: Record = { + terminalSurfaceCloseMutation: [], + persistPtyBinding: [ + 'ipc/pty/ipc/spawn-commit-persist.ts', + 'ipc/pty/pane/stable-owner.ts', + 'ipc/pty/runtime/spawn-commit.ts', + 'ssh/ssh-relay-session.ts' + ], + // Several runtime files only check it exists, then write through setWorkspaceSessionForWorktree. + setWorkspaceSession: [ + 'ipc/pty/pane/stable-owner.ts', + 'ipc/session.ts', + // Store-internal: patchWorkspaceSession -> setWorkspaceSession. + 'persistence/loading-store/session-snapshot-operations.ts', + // Test support: seeds sessions for the acknowledged-tab retirement audit. + 'runtime/acknowledged-terminal-tab-retirement-fixture.ts', + 'runtime/client-hosted-browser-page-persistence.ts', + 'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts', + 'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts', + 'runtime/orca-runtime-attach-window.ts', + 'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts', + 'runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'runtime/orca-runtime-persist-headless-session-tab-props.ts', + 'runtime/orca-runtime-persist-headless-terminal-title.ts', + 'runtime/orca-runtime-persist-terminal-surface-retirements.ts', + 'runtime/orca-runtime-pty-foreground-process-reads.ts', + 'runtime/orca-runtime-stop-terminals-for-worktree.ts', + 'runtime/runtime-legacy-worker-terminal-recovery-persistence.ts', + 'runtime/runtime-workspace-session-controller.ts' + ], + // The partition sinks under setWorkspaceSession and stageWorkspaceSessionBeforeUnload. + setLocalWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], + setHostWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], + // The runtime's session controller, reachable from every OrcaRuntime mixin. + setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], + patchWorkspaceSession: ['ipc/session.ts'], + stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'], + setWorkspaceSessionForWorktree: [ + 'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts', + 'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts', + 'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts', + 'runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'runtime/orca-runtime-persist-headless-session-tab-props.ts', + 'runtime/orca-runtime-persist-headless-terminal-title.ts', + 'runtime/orca-runtime-pty-foreground-process-reads.ts' + ] +} + +/** A writer's own definition (a function or class member), as opposed to any other mention. */ +function isDefinitionName(node: ts.Node): boolean { + const parent = node.parent + return ( + (ts.isFunctionDeclaration(parent) || + (ts.isClassElement(parent) && ts.isClassLike(parent.parent))) && + parent.name === node + ) +} + +function isTypeOnlyImportOrExport(node: ts.Node): boolean { + return ( + ((ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) && node.isTypeOnly) || + (ts.isImportClause(node) && node.isTypeOnly) || + (ts.isExportDeclaration(node) && node.isTypeOnly) + ) +} + +/** Any name or string literal except the writer's own definition; types are skipped. */ +function referencedName(node: ts.Node): string | undefined { + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) { + return isDefinitionName(node) ? undefined : node.text + } + return undefined +} + +function referencingFilesByWriter(): Map> { + const writers = Object.keys(ALLOWED_REFERENCES) + const references = new Map(writers.map((writer) => [writer, new Set()])) + for (const file of scanSourceTree(MAIN_ROOT)) { + // Why prefilter: parsing every main-process file would dominate the test's budget. A matched + // name appears verbatim in the text unless it is spelled with an escape. + if ( + file.relativePath.startsWith(BOUNDARY_DIR) || + (!file.source.includes('\\') && !writers.some((writer) => file.source.includes(writer))) + ) { + continue + } + const source = ts.createSourceFile(file.relativePath, file.source, ts.ScriptTarget.Latest, true) + const visit = (node: ts.Node): void => { + // Why: ExpressionWithTypeArguments is a type node but also holds `extends f(x)` and `x`. + const typeOnly = + (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) || + ts.isInterfaceDeclaration(node) || + isTypeOnlyImportOrExport(node) + if (typeOnly) { + return + } + const name = referencedName(node) + if (name !== undefined) { + references.get(name)?.add(file.relativePath) + } + ts.forEachChild(node, visit) + } + visit(source) + } + return references +} + +describe('terminal topology boundary ratchet', () => { + const references = referencingFilesByWriter() + + for (const [writer, allowed] of Object.entries(ALLOWED_REFERENCES)) { + it(`only the boundary and listed files reference ${writer}`, () => { + expect(references.get(writer)).toEqual(new Set(allowed)) + }) + } +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts new file mode 100644 index 00000000000..8b086c56e39 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import type { TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' +import { closeLeafOrTab } from './terminal-topology-commit' + +// An ssh: partition, so a leaked host id or worktree path would show in the span. +const HOST_ID: ExecutionHostId = 'ssh:target-1' +const WORKTREE_ID = 'ssh-repo::/srv/app' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const SPLIT_TAB = 'tab-split' +const PINNED_TAB = 'tab-pinned' +const CLOSED_TAB = 'tab-closed-earlier' +const NOW = 1_700_000_000_000 + +function tab(id: string, ptyId: string, isPinned = false) { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...(isPinned ? { isPinned } : {}) + } +} + +function session(): WorkspaceSessionState { + return { + activeRepoId: 'ssh-repo', + activeWorktreeId: WORKTREE_ID, + activeTabId: SPLIT_TAB, + tabsByWorktree: { [WORKTREE_ID]: [tab(SPLIT_TAB, 'pty-1'), tab(PINNED_TAB, 'pty-3', true)] }, + terminalLayoutsByTabId: { + [SPLIT_TAB]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1', [LEAF_2]: 'pty-2' } + } + }, + closedTerminalTabTombstonesByTabId: { + [CLOSED_TAB]: { closedAt: NOW - 1000, worktreeId: WORKTREE_ID, reason: 'user' } + }, + terminalTopologyRevisionByRepoId: { 'ssh-repo': 3 } + } +} + +function commitFor( + target: TerminalSurfaceCloseTarget, + overrides: Partial = {} +): TerminalSurfaceCloseCommit { + let current = session() + return { + worktreeId: WORKTREE_ID, + target, + options: {}, + requestedSession: current, + ownerMatches: () => true, + hostId: () => HOST_ID, + getSession: () => current, + setSession: (next) => { + current = next + }, + onClosed: () => {}, + ...overrides + } +} + +describe('persistence.terminal-topology span', () => { + let records: { name: string; attributes: Record; exit: unknown }[] + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + records = [] + setActiveSink({ + push: (record) => { + records.push(JSON.parse(JSON.stringify(record))) + }, + flush: () => {}, + close: () => {} + }) + }) + afterEach(() => { + vi.useRealTimers() + _resetTracerForTests() + }) + + function attributesAfter(commit: TerminalSurfaceCloseCommit): Record { + closeLeafOrTab(commit)() + expect(records).toHaveLength(1) + expect(records[0].name).toBe('persistence.terminal-topology') + return records[0].attributes + } + + it('records a committed pane close without ids', () => { + expect(attributesAfter(commitFor({ kind: 'pane', tabId: SPLIT_TAB, leafId: LEAF_2 }))).toEqual({ + kind: 'persistence', + 'topology.kind': 'close_leaf', + 'topology.outcome': 'committed' + }) + expect(JSON.stringify(records[0])).not.toMatch(/pty-|tab-split|ssh-repo|srv|target-1/) + }) + + it('records a committed tab close', () => { + expect(attributesAfter(commitFor({ kind: 'tab', tabId: SPLIT_TAB }))).toMatchObject({ + 'topology.kind': 'close_tab', + 'topology.outcome': 'committed' + }) + }) + + it('records a refusal with its reason code', () => { + expect(attributesAfter(commitFor({ kind: 'tab', tabId: PINNED_TAB }))).toMatchObject({ + 'topology.kind': 'close_tab', + 'topology.outcome': 'refused', + 'topology.refusal': 'terminal_tab_pinned' + }) + }) + + it('records a close that changes nothing as a noop', () => { + const echo = commitFor({ kind: 'tab', tabId: CLOSED_TAB }, { options: { allowMissing: true } }) + expect(attributesAfter(echo)).toMatchObject({ 'topology.outcome': 'noop' }) + }) + + it('records a thrown commit as a failed span and rethrows', () => { + const mutation = closeLeafOrTab( + commitFor( + { kind: 'tab', tabId: SPLIT_TAB }, + { + getSession: () => { + throw new Error('read failed') + } + } + ) + ) + expect(mutation).toThrow('read failed') + expect(records).toHaveLength(1) + expect(records[0].attributes).toMatchObject({ 'topology.outcome': 'threw' }) + expect(records[0].exit).toMatchObject({ _tag: 'Failure' }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts new file mode 100644 index 00000000000..dee0f6283a9 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -0,0 +1,57 @@ +import { startSpan } from '../../observability/tracer' +import { + terminalSurfaceCloseMutation, + type TerminalSurfaceCloseCommit +} from '../../runtime/terminal-surface-close' +import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' + +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Today it wraps only +// the close, whose transform still lives in runtime/; the other writers move here later. + +/** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ +type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' + +export function closeLeafOrTab( + commit: TerminalSurfaceCloseCommit +): () => DurableProfileStateMutation { + return traced( + commit.target.kind === 'pane' ? 'close_leaf' : 'close_tab', + terminalSurfaceCloseMutation(commit), + // Refusals are fixed reason codes, never ids. + (refusal) => refusal?.message + ) +} + +/** + * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. + * Attributes stay low-cardinality: no pane key, PTY id or path. + */ +function traced( + kind: TerminalTopologyCommitKind, + mutate: () => DurableProfileStateMutation, + refusalOf: (value: T) => string | undefined +): () => DurableProfileStateMutation { + return () => { + const span = startSpan('persistence.terminal-topology', { + attributes: { kind: 'persistence', 'topology.kind': kind } + }) + let result: DurableProfileStateMutation + // Why only mutate(): `threw` must mean the write failed, never that tracing did. + try { + result = mutate() + } catch (error) { + span.setAttribute('topology.outcome', 'threw') + span.fail(error instanceof Error ? error : String(error)) + throw error + } + const refusal = refusalOf(result.value) + if (refusal !== undefined) { + span.setAttribute('topology.outcome', 'refused') + span.setAttribute('topology.refusal', refusal) + } else { + span.setAttribute('topology.outcome', result.persist === false ? 'noop' : 'committed') + } + span.end() + return result + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-membership.ts b/src/main/persistence/terminal-topology/terminal-topology-membership.ts new file mode 100644 index 00000000000..99437157437 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-membership.ts @@ -0,0 +1,56 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' + +export function advanceTerminalTopologyRevision( + session: WorkspaceSessionState, + worktreeId: string +): WorkspaceSessionState { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return { + ...session, + terminalTopologyRevisionByRepoId: { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 + } + } +} + +/** + * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — + * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the + * pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId. + * + * Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the + * layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read — + * the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded + * `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured). + */ +export function findTerminalTabIdForLeaf( + session: WorkspaceSessionState | undefined, + leafId: string +): string | undefined { + const layouts = session?.terminalLayoutsByTabId + if (!layouts) { + return undefined + } + for (const tabId of Object.keys(layouts)) { + if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) { + return tabId + } + } + return undefined +} + +export function hasHostAuthoritativeTerminalMembership( + session: WorkspaceSessionState | undefined, + worktreeId: string +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return ( + (session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 || + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some( + (tombstone) => tombstone.worktreeId === worktreeId + ) + ) +} diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 12b3340d2ac..32af5b46dd5 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -12,7 +12,7 @@ import { Store } from '../persistence/loading-store/store' import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' export const ACK_WORKTREE = 'repo1::/tmp/worktree' export const ACK_TAB = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 179dba29bd1..6e55edb32df 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -7,7 +7,7 @@ import { ACK_WORKTREE, createAcknowledgedTabRetirementFixture } from './acknowledged-terminal-tab-retirement-fixture' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome' const fixtures: ReturnType[] = [] diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index f08c9a4a705..25ed04c2f32 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -6,10 +6,8 @@ import { retireLeavesFromTerminalLayout, type RetiredTerminalSurface } from './mobile-session-terminal-retirement' -import { - advanceTerminalTopologyRevision, - rebaseWorkspaceSessionTerminalMembership -} from './workspace-session-terminal-membership-authority' +import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' function visibleTypeForContentType( contentType: string | undefined diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index a4d01045475..35f9e7991a5 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -12,7 +12,6 @@ import { holdAgentSessionInventory } from './structured-agent-session-inventory- import type { Tab } from '../../shared/tab-types' import { resolveTerminalCloseTarget, - terminalSurfaceCloseMutation, type PaneCloseResolution, type RendererTerminalClose, type TerminalSurfaceCloseOptions @@ -25,6 +24,7 @@ import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-re import type { PtyControllerInventory } from './runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' +import { closeLeafOrTab } from '../persistence/terminal-topology/terminal-topology-commit' export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { // Why: headless serve backs browser panes with offscreen WebContents that live @@ -131,7 +131,7 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu let refusal: Error | undefined try { refusal = await store.runDurableMutation( - terminalSurfaceCloseMutation({ + closeLeafOrTab({ worktreeId, target, options, diff --git a/src/main/runtime/orca-runtime-get-runtime-id.ts b/src/main/runtime/orca-runtime-get-runtime-id.ts index ee7fe60e85e..59da5242d64 100644 --- a/src/main/runtime/orca-runtime-get-runtime-id.ts +++ b/src/main/runtime/orca-runtime-get-runtime-id.ts @@ -188,7 +188,7 @@ export class OrcaRuntimeWithGetRuntimeId extends OrcaRuntimeWithHasExactPersiste worktreeId: string, session: WorkspaceSessionState ): void { - this.workspaceSessions.set(worktreeId, session) + this.workspaceSessions.setForWorktree(worktreeId, session) } protected getKnownWorkspaceSessionWorktreeIds(): Set { diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index ddb74df4dc8..6ce5f8a3650 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -15,7 +15,7 @@ import { sshRemotePtyLeaseAllowsReattach } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { RuntimeStore } from './runtime-store-contract' import { SSH_PANE_RECOVERY_GRACE_MS } from './orca-runtime-core' -import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership' export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession { // Why: keep an existing snapshot's browser tabs in sync with the live bridge diff --git a/src/main/runtime/orca-runtime-terminal-close-records.test.ts b/src/main/runtime/orca-runtime-terminal-close-records.test.ts index f05ac78957a..28bb3e62d2e 100644 --- a/src/main/runtime/orca-runtime-terminal-close-records.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-records.test.ts @@ -19,7 +19,7 @@ import { WORKTREE_PATH, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const SSH_REPO_ID = 'ssh-repo' const SSH_HOST_ID = 'ssh:target-1' diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts index c9bd30b7199..98e22b90b56 100644 --- a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -23,7 +23,7 @@ import { retireTerminalSurfaceFromPersistence, sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const splitLayout = { root: { diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index 3c18eb68def..57621cc5113 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -2,7 +2,7 @@ import { OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal } from './orca-runtime-publish-pty-backed-mobile-session-terminal' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parsePaneKey } from '../../shared/stable-pane-id' -import { hasHostAuthoritativeTerminalMembership } from './workspace-session-terminal-membership-authority' +import { hasHostAuthoritativeTerminalMembership } from '../persistence/terminal-topology/terminal-topology-membership' import type { RuntimeMobileSessionTabsSnapshot, RuntimeSyncedLeaf diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index 106799cf7e4..e2a4cd1df5d 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -5,7 +5,7 @@ import { collectPersistedTerminalLeafIds } from './mobile-session-layout-project import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology' import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' type Claim = RuntimeTerminalOrphanAdoptionRequest['claims'][number] type Topology = NonNullable diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index e45425b8dd3..f6bb0014169 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -116,7 +116,7 @@ export class RuntimeWorkspaceSessionController { : null } - set(worktreeId: string, session: WorkspaceSessionState): void { + setForWorktree(worktreeId: string, session: WorkspaceSessionState): void { this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId)) } diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts index 0c3f996153f..279955fde1f 100644 --- a/src/main/runtime/terminal-intentional-stop-exit.test.ts +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -29,7 +29,7 @@ import { WORKTREE_PATH, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' diff --git a/src/main/runtime/terminal-leaf-tab-resolution.test.ts b/src/main/runtime/terminal-leaf-tab-resolution.test.ts index 2fe8a6d0344..8f57c05f59c 100644 --- a/src/main/runtime/terminal-leaf-tab-resolution.test.ts +++ b/src/main/runtime/terminal-leaf-tab-resolution.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership' function layout(...leafIds: string[]): TerminalLayoutSnapshot { let root = { type: 'leaf' as const, leafId: leafIds[0] } diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 0feee12402c..636834a6178 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -14,7 +14,7 @@ import { type WorkspaceSessionTerminalTabCloseResult } from '../../shared/workspace-session-terminal-tab-close' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import type { ExecutionHostId } from '../../shared/execution-host' diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index 7798ae7831c..4c2ea719da5 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -5,7 +5,6 @@ import type { } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization' import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { @@ -148,59 +147,6 @@ function rebaseIncarnationBindings( return Object.keys(retained).length > 0 ? retained : undefined } -export function advanceTerminalTopologyRevision( - session: WorkspaceSessionState, - worktreeId: string -): WorkspaceSessionState { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return { - ...session, - terminalTopologyRevisionByRepoId: { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 - } - } -} - -/** - * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — - * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the - * pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId. - * - * Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the - * layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read — - * the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded - * `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured). - */ -export function findTerminalTabIdForLeaf( - session: WorkspaceSessionState | undefined, - leafId: string -): string | undefined { - const layouts = session?.terminalLayoutsByTabId - if (!layouts) { - return undefined - } - for (const tabId of Object.keys(layouts)) { - if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) { - return tabId - } - } - return undefined -} - -export function hasHostAuthoritativeTerminalMembership( - session: WorkspaceSessionState | undefined, - worktreeId: string -): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return ( - (session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 || - Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some( - (tombstone) => tombstone.worktreeId === worktreeId - ) - ) -} - export function rebaseWorkspaceSessionTerminalMembership( incoming: WorkspaceSessionState, prior: WorkspaceSessionState | undefined diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 889f792abd4..d4c795817a3 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -183,7 +183,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () ) const { findTerminalTabIdForLeaf } = - await import('./runtime/workspace-session-terminal-membership-authority') + await import('./persistence/terminal-topology/terminal-topology-membership') // The relay resolves the tab from the live layout before binding, exactly as the production // path does; forwarding the lease's frozen `TAB` here is what would strand the pane. const resolvedTabId = findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2cd1f394cd7..63516856036 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -94,7 +94,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { findTerminalTabIdForLeaf, hasHostAuthoritativeTerminalMembership -} from '../runtime/workspace-session-terminal-membership-authority' +} from '../persistence/terminal-topology/terminal-topology-membership' import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' import {