diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bde0b5e05d8..a3de28676bb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,6 +28,7 @@ jobs: outputs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -95,6 +96,15 @@ jobs: - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware + # Why: the changed-code gate lints mobile files too, and its type-aware pass + # resolves types from mobile/node_modules. Mobile is a separate pnpm project, + # so the root install above leaves it empty and every mobile type degrades to + # an `error` type — reported as phantom findings against the changed lines. + - name: Install mobile dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' + working-directory: mobile + run: pnpm install --frozen-lockfile + - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 67d4f565afa..da00bb22384 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -261,6 +261,14 @@ export function shouldRunPrChecks(changedFiles) { return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file)) } +export function needsMobileDependencies(changedFiles) { + // Why: static analysis lints CHANGED files, mobile ones included, and its + // type-aware pass resolves types from mobile/node_modules. Mobile is a + // separate pnpm project, so without this the root-only install leaves every + // mobile type an `error` type and the gate reports phantom findings. + return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/')) +} + export function classifyPrJobs(changedFiles) { const emptyDiff = changedFiles.length === 0 const shouldRun = shouldRunPrChecks(changedFiles) @@ -274,6 +282,7 @@ export function classifyPrJobs(changedFiles) { return { should_run: shouldRun, native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), + mobile_dependencies: needsMobileDependencies(changedFiles), ...jobs } } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 9a1c9e649b6..846b92769e3 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -305,6 +305,20 @@ describe('per-job path classification', () => { } }) + // Why: static analysis lints changed mobile files with a type-aware pass, and + // mobile is a separate pnpm project. Without its node_modules every mobile type + // resolves to an `error` type and the changed-code gate fails on phantom + // findings, which is exactly how a react-test-renderer union broke a PR. + it('installs mobile dependencies exactly when mobile files change', () => { + expect(classifyPrJobs([]).mobile_dependencies).toBe(true) + expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false) + expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false) + expect( + classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies + ).toBe(true) + expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(true) + }) + it('keeps unit-test-only diffs out of packaging', () => { expectClassification(['src/main/git/git-status.test.ts'], { git_compatibility: true @@ -343,6 +357,20 @@ describe('PR Checks skip wiring', () => { } }) + it('gives static analysis the mobile types its type-aware pass resolves', () => { + expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( + '${{ steps.filter.outputs.mobile_dependencies }}' + ) + const steps = prWorkflow.jobs.static_analysis.steps + const install = steps.findIndex((step) => step.name === 'Install mobile dependencies') + const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') + expect(install).toBeGreaterThan(-1) + expect(install).toBeLessThan(gate) + expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install]['working-directory']).toBe('mobile') + expect(steps[install].run).toContain('--frozen-lockfile') + }) + it('keeps the cheap root-directory guard on docs-only PRs', () => { expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined() expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index b1480259599..7d6a0eadc3e 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -150,8 +150,9 @@ describe('host-session-mirror settle census', () => { 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': 1, // The eager post-create session.tabs.list refresh. 'runtime/web-runtime-session-snapshot.ts': 1, - // The local structured-session inventory/subscription frame. - 'runtime/local-structured-session-tabs-sync.ts': 1 + // The local structured-session mirror owns two: the inventory/subscription + // frame, and the toggle-off teardown that retracts the tabs it published. + 'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': 2 }) }) @@ -196,14 +197,19 @@ describe('host-session-mirror settle census', () => { // Hydration and mirror receipts remain pinned by their extracted owners: // the global singular frame owns two hydration completions and the global // inventory frame one, initial loading owns one, active subscription owns - // two mirror settles, and visibility resume repair owns one. + // two mirror settles, and visibility resume repair owns one. The local + // structured-session apply module owns one settle per direction: the + // snapshot it mirrors in, and the teardown that retracts it. 'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 }, 'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 2 }, 'runtime/web-session-tabs-sync/global-session-inventory-event.ts': { settleHydration: 1 }, 'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 }, 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': { settle: 1 }, 'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 }, - 'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 } + 'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': { + settleStructuredSessionClear: 1, + settleStructuredSessionMirror: 1 + } }) }) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 893478abc0c..722bd93af8b 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -1,309 +1,22 @@ import { useEffect } from 'react' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { useAppStore } from '../store' -import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' -import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner' -import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync' -import type { WebSessionTabsSyncState } from './web-session-tabs-sync' -import { - noteRetiredValue, - sameSessionTabsPublicationLineage -} from './web-session-tabs-sync/publisher-identity-fences' -import type { SessionTabsPublicationEpochHistory } from './web-session-tabs-sync/state' -import { refreshLocalRuntimeCapabilities } from './local-runtime-capabilities' -import { - knownStructuredSessionWorktreeIds, - removeStructuredSessionTabsForVersions, - type StructuredSessionTabPublicationVersion -} from './local-structured-session-tab-retirement' +import { clearLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-apply' +import { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' -export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' -let localStructuredSessionTabsRestorePromise: Promise | null = null -let localStructuredSessionSyncGeneration = 0 -const localStructuredSessionVersionByWorktree = new Map< - string, - StructuredSessionTabPublicationVersion ->() -const localStructuredSessionEpochHistoryByWorktree = new Map< - string, - SessionTabsPublicationEpochHistory ->() - -export function resetLocalStructuredSessionVersionForTests(): void { - localStructuredSessionSyncGeneration += 1 - localStructuredSessionVersionByWorktree.clear() - localStructuredSessionEpochHistoryByWorktree.clear() -} - -type SessionTabsEvent = - | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } - | { type: 'end' } - -export function projectLocalStructuredSessionTabs( - snapshot: RuntimeMobileSessionTabsResult -): RuntimeMobileSessionTabsResult { - const structuredIds = new Set( - snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) - ) - const visibleHostTabIds = structuredIds - const visibleIds = structuredIds - const projectedTabGroups = snapshot.tabGroups - ?.map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)), - activeTabId: - group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null, - recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id)) - })) - .filter((group) => group.tabOrder.length > 0) - - return { - ...snapshot, - activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null, - activeTabType: - snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null, - activeGroupId: - snapshot.activeGroupId && - projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId) - ? snapshot.activeGroupId - : (projectedTabGroups?.[0]?.id ?? null), - tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)), - tabGroups: projectedTabGroups, - // Why: group membership locates chats; the renderer's split tree remains locally authoritative. - tabGroupLayout: undefined - } -} - -export function applyStructuredSessionTabSnapshots( - snapshots: readonly RuntimeMobileSessionTabsResult[], - owner = LOCAL_STRUCTURED_SESSION_OWNER -): void { - const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( - (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner), - { frames: [] } - ) - settleStructuredSessionMirror() -} - -export function removeLocalStructuredSessionTabs< - State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState ->(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State { - return removeStructuredSessionTabsForVersions( - state, - localStructuredSessionVersionByWorktree, - owner, - now - ) -} - -export function clearLocalStructuredSessionTabs(): void { - // Fence responses from the previous enabled instance before clearing its mirror. - localStructuredSessionSyncGeneration += 1 - const settle = applyWebSessionTabsStorePatch((state) => removeLocalStructuredSessionTabs(state), { - frames: [] - }) - settle() - localStructuredSessionTabsRestorePromise = null - localStructuredSessionVersionByWorktree.clear() - localStructuredSessionEpochHistoryByWorktree.clear() -} - -export function applyLocalStructuredSessionTabSnapshots< - State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState ->( - state: State, - snapshots: readonly RuntimeMobileSessionTabsResult[], - owner = LOCAL_STRUCTURED_SESSION_OWNER, - now = Date.now() -): State { - let next = state - for (const snapshot of snapshots) { - // Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes. - if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { - continue - } - const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree) - const sharesLineage = Boolean( - prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch) - ) - const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree) - if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) { - continue - } - if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) { - continue - } - const patch = applyWebSessionTabsSnapshot( - next, - projectLocalStructuredSessionTabs(snapshot), - owner, - now, - { - contentScope: 'agent-session', - preserveLocalLayout: true, - terminalPtyMode: 'local' - } - ) - next = patch === next ? next : ({ ...next, ...patch } as State) - localStructuredSessionVersionByWorktree.set(snapshot.worktree, { - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion - }) - localStructuredSessionEpochHistoryByWorktree.set( - snapshot.worktree, - noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8) - ) - } - // Drop publisher cursors for worktrees that no longer exist. Without this, - // every deleted worktree leaves an entry for the lifetime of the renderer. - const knownWorktreeIds = knownStructuredSessionWorktreeIds(next) - for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) { - if (!knownWorktreeIds.has(worktreeId)) { - localStructuredSessionVersionByWorktree.delete(worktreeId) - localStructuredSessionEpochHistoryByWorktree.delete(worktreeId) - } - } - return next -} - -export function restoreLocalStructuredSessionTabsOnce( - expectedGeneration = localStructuredSessionSyncGeneration -): Promise { - localStructuredSessionTabsRestorePromise ??= refreshLocalRuntimeCapabilities() - .then(() => refreshLocalStructuredSessionTabs(expectedGeneration)) - .then(() => undefined) - .catch((error) => { - localStructuredSessionTabsRestorePromise = null - throw error - }) - return localStructuredSessionTabsRestorePromise -} - -/** Fetch the current host inventory even after the startup restore has settled. */ -export function refreshLocalStructuredSessionTabs( - expectedGeneration = localStructuredSessionSyncGeneration -): Promise { - return window.api.runtime - .call({ method: 'session.tabs.listAll', params: {} }) - .then((response) => { - if (!response.ok) { - throw new Error('structured session inventory unavailable') - } - const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } - const snapshots = result.snapshots ?? [] - if (expectedGeneration === localStructuredSessionSyncGeneration) { - applyStructuredSessionTabSnapshots(snapshots) - } - return snapshots - }) -} - -export async function startLocalStructuredSessionTabsSync(args: { - isDisposed: () => boolean - setUnsubscribe: (unsubscribe: () => void) => void -}): Promise { - const syncGeneration = localStructuredSessionSyncGeneration - const isCurrent = (): boolean => - !args.isDisposed() && syncGeneration === localStructuredSessionSyncGeneration - const capabilities = await refreshLocalRuntimeCapabilities() - if (!isCurrent()) { - return - } - const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - await restoreLocalStructuredSessionTabsOnce(syncGeneration) - if (!isCurrent()) { - return - } - if (!supported) { - return - } - let subscriptionGeneration = 0 - let reconnectTimer: ReturnType | null = null - let reconnectAttempt = 0 - let activeHandle: { unsubscribe: () => void } | null = null - const scheduleSubscribeRetry = (): void => { - if (!isCurrent() || reconnectTimer !== null) { - return - } - const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000) - reconnectAttempt += 1 - reconnectTimer = setTimeout(() => { - reconnectTimer = null - void refreshLocalStructuredSessionTabs(syncGeneration) - .catch((error) => console.warn('[structured-session-tabs] resync failed', error)) - .finally(() => { - if (isCurrent()) { - void subscribeCurrent().catch((error) => { - console.warn('[structured-session-tabs] resubscribe failed', error) - scheduleSubscribeRetry() - }) - } - }) - }, reconnectDelay) - } - const subscribeCurrent = async (): Promise => { - if (!isCurrent()) { - return - } - const generation = ++subscriptionGeneration - let handle: { unsubscribe: () => void } | null = null - handle = await window.api.runtime.subscribe( - { method: 'session.tabs.subscribeAll', params: {} }, - (response) => { - if (!isCurrent() || generation !== subscriptionGeneration) { - return - } - if (!response.ok) { - // A streaming RPC can terminate with an error response before its - // handle resolves; fence that generation and retry the subscription. - subscriptionGeneration += 1 - handle?.unsubscribe() - if (activeHandle === handle) { - activeHandle = null - } - scheduleSubscribeRetry() - return - } - const event = response.result as SessionTabsEvent - if (event.type === 'snapshots') { - applyStructuredSessionTabSnapshots(event.snapshots) - } else if (event.type === 'snapshot' || event.type === 'updated') { - applyStructuredSessionTabSnapshots([event]) - } else if (event.type === 'end' && generation === subscriptionGeneration) { - // Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs. - subscriptionGeneration += 1 - handle?.unsubscribe() - if (activeHandle === handle) { - activeHandle = null - } - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer) - } - scheduleSubscribeRetry() - } - } - ) - if (!isCurrent() || generation !== subscriptionGeneration) { - handle.unsubscribe() - } else { - activeHandle = handle - } - } - args.setUnsubscribe(() => { - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - activeHandle?.unsubscribe() - activeHandle = null - }) - void subscribeCurrent().catch((error) => { - console.warn('[structured-session-tabs] subscribe failed', error) - scheduleSubscribeRetry() - }) -} +export { resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync/inventory-generation-fence' +export { + refreshLocalStructuredSessionTabs, + restoreLocalStructuredSessionTabsOnce +} from './local-structured-session-tabs-sync/inventory-refresh' +export { + applyLocalStructuredSessionTabSnapshots, + applyStructuredSessionTabSnapshots, + clearLocalStructuredSessionTabs, + LOCAL_STRUCTURED_SESSION_OWNER, + removeLocalStructuredSessionTabs +} from './local-structured-session-tabs-sync/snapshot-apply' +export { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-projection' +export { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' export function useLocalStructuredSessionTabsSync(): void { const ready = useAppStore( diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts new file mode 100644 index 00000000000..8ab68385f9e --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts @@ -0,0 +1,57 @@ +import type { SessionTabsPublicationEpochHistory } from '../web-session-tabs-sync/state' +import type { StructuredSessionTabPublicationVersion } from '../local-structured-session-tab-retirement' + +// Everything a toggle-off must invalidate: which publisher instance the renderer +// is listening to, which publication it already accepted per worktree, and the +// one-shot startup restore. A response in flight for a superseded instance must +// never reach the mirror, so every async entry point carries the generation it +// was started under and re-checks it before applying. +let syncGeneration = 0 +let restorePromise: Promise | null = null + +export const localStructuredSessionVersionByWorktree = new Map< + string, + StructuredSessionTabPublicationVersion +>() +export const localStructuredSessionEpochHistoryByWorktree = new Map< + string, + SessionTabsPublicationEpochHistory +>() + +export function localStructuredSessionGeneration(): number { + return syncGeneration +} + +export function isCurrentLocalStructuredSessionGeneration(generation: number): boolean { + return generation === syncGeneration +} + +/** Retire the current publisher instance: responses already in flight stop applying. */ +export function supersedeLocalStructuredSessionGeneration(): void { + syncGeneration += 1 +} + +// Separate from superseding because a teardown still has to publish the retiring +// cursors as retracted tabs before it may forget them. +export function forgetLocalStructuredSessionPublicationCursors(): void { + localStructuredSessionVersionByWorktree.clear() + localStructuredSessionEpochHistoryByWorktree.clear() +} + +export function dropLocalStructuredSessionRestoreLatch(): void { + restorePromise = null +} + +/** Latch the startup restore, releasing it on failure so a retry can re-run it. */ +export function latchLocalStructuredSessionRestore(start: () => Promise): Promise { + restorePromise ??= start().catch((error: unknown) => { + restorePromise = null + throw error + }) + return restorePromise +} + +export function resetLocalStructuredSessionVersionForTests(): void { + supersedeLocalStructuredSessionGeneration() + forgetLocalStructuredSessionPublicationCursors() +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts new file mode 100644 index 00000000000..d0f29ec0cf8 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts @@ -0,0 +1,37 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities' +import { + isCurrentLocalStructuredSessionGeneration, + latchLocalStructuredSessionRestore, + localStructuredSessionGeneration +} from './inventory-generation-fence' +import { applyStructuredSessionTabSnapshots } from './snapshot-apply' + +export function restoreLocalStructuredSessionTabsOnce( + expectedGeneration = localStructuredSessionGeneration() +): Promise { + return latchLocalStructuredSessionRestore(() => + refreshLocalRuntimeCapabilities() + .then(() => refreshLocalStructuredSessionTabs(expectedGeneration)) + .then(() => undefined) + ) +} + +/** Fetch the current host inventory even after the startup restore has settled. */ +export function refreshLocalStructuredSessionTabs( + expectedGeneration = localStructuredSessionGeneration() +): Promise { + return window.api.runtime + .call({ method: 'session.tabs.listAll', params: {} }) + .then((response) => { + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + const snapshots = result.snapshots ?? [] + if (isCurrentLocalStructuredSessionGeneration(expectedGeneration)) { + applyStructuredSessionTabSnapshots(snapshots) + } + return snapshots + }) +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts new file mode 100644 index 00000000000..fc254de62dc --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -0,0 +1,118 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { WorktreeRuntimeOwnerState } from '../../lib/worktree-runtime-owner' +import { getExecutionHostIdForWorktree } from '../../lib/worktree-runtime-owner' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch +} from '../web-session-tabs-sync' +import type { WebSessionTabsSyncState } from '../web-session-tabs-sync' +import { + noteRetiredValue, + sameSessionTabsPublicationLineage +} from '../web-session-tabs-sync/publisher-identity-fences' +import { + knownStructuredSessionWorktreeIds, + removeStructuredSessionTabsForVersions +} from '../local-structured-session-tab-retirement' +import { + dropLocalStructuredSessionRestoreLatch, + forgetLocalStructuredSessionPublicationCursors, + localStructuredSessionEpochHistoryByWorktree, + localStructuredSessionVersionByWorktree, + supersedeLocalStructuredSessionGeneration +} from './inventory-generation-fence' +import { projectLocalStructuredSessionTabs } from './snapshot-projection' + +export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' + +export function applyStructuredSessionTabSnapshots( + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER +): void { + const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( + (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner), + { frames: [] } + ) + settleStructuredSessionMirror() +} + +export function removeLocalStructuredSessionTabs< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State { + return removeStructuredSessionTabsForVersions( + state, + localStructuredSessionVersionByWorktree, + owner, + now + ) +} + +export function clearLocalStructuredSessionTabs(): void { + // Fence responses from the previous enabled instance before clearing its mirror. + supersedeLocalStructuredSessionGeneration() + const settleStructuredSessionClear = applyWebSessionTabsStorePatch( + (state) => removeLocalStructuredSessionTabs(state), + { frames: [] } + ) + settleStructuredSessionClear() + dropLocalStructuredSessionRestoreLatch() + forgetLocalStructuredSessionPublicationCursors() +} + +export function applyLocalStructuredSessionTabSnapshots< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>( + state: State, + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER, + now = Date.now() +): State { + let next = state + for (const snapshot of snapshots) { + // Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes. + if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { + continue + } + const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree) + const sharesLineage = Boolean( + prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch) + ) + const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree) + if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) { + continue + } + if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) { + continue + } + const patch = applyWebSessionTabsSnapshot( + next, + projectLocalStructuredSessionTabs(snapshot), + owner, + now, + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + next = patch === next ? next : ({ ...next, ...patch } as State) + localStructuredSessionVersionByWorktree.set(snapshot.worktree, { + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion + }) + localStructuredSessionEpochHistoryByWorktree.set( + snapshot.worktree, + noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8) + ) + } + // Drop publisher cursors for worktrees that no longer exist. Without this, + // every deleted worktree leaves an entry for the lifetime of the renderer. + const knownWorktreeIds = knownStructuredSessionWorktreeIds(next) + for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) { + if (!knownWorktreeIds.has(worktreeId)) { + localStructuredSessionVersionByWorktree.delete(worktreeId) + localStructuredSessionEpochHistoryByWorktree.delete(worktreeId) + } + } + return next +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts new file mode 100644 index 00000000000..b6d7afc5048 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts @@ -0,0 +1,37 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +/** Narrow a host inventory snapshot to the structured agent-session tabs it publishes. */ +export function projectLocalStructuredSessionTabs( + snapshot: RuntimeMobileSessionTabsResult +): RuntimeMobileSessionTabsResult { + const structuredIds = new Set( + snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) + ) + const visibleHostTabIds = structuredIds + const visibleIds = structuredIds + const projectedTabGroups = snapshot.tabGroups + ?.map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)), + activeTabId: + group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null, + recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id)) + })) + .filter((group) => group.tabOrder.length > 0) + + return { + ...snapshot, + activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null, + activeTabType: + snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null, + activeGroupId: + snapshot.activeGroupId && + projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId) + ? snapshot.activeGroupId + : (projectedTabGroups?.[0]?.id ?? null), + tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)), + tabGroups: projectedTabGroups, + // Why: group membership locates chats; the renderer's split tree remains locally authoritative. + tabGroupLayout: undefined + } +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts new file mode 100644 index 00000000000..b074cfb1c41 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts @@ -0,0 +1,122 @@ +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities' +import { + isCurrentLocalStructuredSessionGeneration, + localStructuredSessionGeneration +} from './inventory-generation-fence' +import { + refreshLocalStructuredSessionTabs, + restoreLocalStructuredSessionTabsOnce +} from './inventory-refresh' +import { applyStructuredSessionTabSnapshots } from './snapshot-apply' + +type SessionTabsEvent = + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | { type: 'end' } + +export async function startLocalStructuredSessionTabsSync(args: { + isDisposed: () => boolean + setUnsubscribe: (unsubscribe: () => void) => void +}): Promise { + const syncGeneration = localStructuredSessionGeneration() + const isCurrent = (): boolean => + !args.isDisposed() && isCurrentLocalStructuredSessionGeneration(syncGeneration) + const capabilities = await refreshLocalRuntimeCapabilities() + if (!isCurrent()) { + return + } + const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + await restoreLocalStructuredSessionTabsOnce(syncGeneration) + if (!isCurrent()) { + return + } + if (!supported) { + return + } + let subscriptionGeneration = 0 + let reconnectTimer: ReturnType | null = null + let reconnectAttempt = 0 + let activeHandle: { unsubscribe: () => void } | null = null + const scheduleSubscribeRetry = (): void => { + if (!isCurrent() || reconnectTimer !== null) { + return + } + const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000) + reconnectAttempt += 1 + reconnectTimer = setTimeout(() => { + reconnectTimer = null + void refreshLocalStructuredSessionTabs(syncGeneration) + .catch((error) => console.warn('[structured-session-tabs] resync failed', error)) + .finally(() => { + if (isCurrent()) { + void subscribeCurrent().catch((error) => { + console.warn('[structured-session-tabs] resubscribe failed', error) + scheduleSubscribeRetry() + }) + } + }) + }, reconnectDelay) + } + const subscribeCurrent = async (): Promise => { + if (!isCurrent()) { + return + } + const generation = ++subscriptionGeneration + let handle: { unsubscribe: () => void } | null = null + handle = await window.api.runtime.subscribe( + { method: 'session.tabs.subscribeAll', params: {} }, + (response) => { + if (!isCurrent() || generation !== subscriptionGeneration) { + return + } + if (!response.ok) { + // A streaming RPC can terminate with an error response before its + // handle resolves; fence that generation and retry the subscription. + subscriptionGeneration += 1 + handle?.unsubscribe() + if (activeHandle === handle) { + activeHandle = null + } + scheduleSubscribeRetry() + return + } + const event = response.result as SessionTabsEvent + if (event.type === 'snapshots') { + applyStructuredSessionTabSnapshots(event.snapshots) + } else if (event.type === 'snapshot' || event.type === 'updated') { + applyStructuredSessionTabSnapshots([event]) + } else if (event.type === 'end' && generation === subscriptionGeneration) { + // Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs. + subscriptionGeneration += 1 + handle?.unsubscribe() + if (activeHandle === handle) { + activeHandle = null + } + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + } + scheduleSubscribeRetry() + } + } + ) + if (!isCurrent() || generation !== subscriptionGeneration) { + handle.unsubscribe() + } else { + activeHandle = handle + } + } + args.setUnsubscribe(() => { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + activeHandle?.unsubscribe() + activeHandle = null + }) + void subscribeCurrent().catch((error) => { + console.warn('[structured-session-tabs] subscribe failed', error) + scheduleSubscribeRetry() + }) +}