diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index fb97a469747..d9c45ae6725 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -24,18 +24,14 @@ inline src/main/daemon/daemon-init.ts inline src/main/daemon/daemon-pty-adapter.ts inline src/main/daemon/daemon-server.ts inline src/main/daemon/pty-subprocess.ts -inline src/main/git/repo.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts -inline src/main/github/pr-refresh-coordinator.ts inline src/main/github/project-view.ts inline src/main/github/project-view/mutations.ts -inline src/main/github/work-item-details.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts -inline src/main/ipc/github.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts @@ -44,12 +40,10 @@ inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts inline src/main/memory/collector.ts inline src/main/opencode/hook-service.ts -inline src/main/persistence/loading-store/store.ts inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts inline src/main/project-groups/nested-repo-discovery.ts inline src/main/providers/local-pty-provider.ts -inline src/main/providers/ssh-git-provider.ts inline src/main/rate-limits/claude-fetcher.ts inline src/main/rate-limits/claude-pty.ts inline src/main/rate-limits/codex-fetcher.ts @@ -80,12 +74,10 @@ inline src/relay/pty-handler.ts inline src/relay/workspace-space-scan.ts inline src/renderer/src/components/GitLabItemDialog.tsx inline src/renderer/src/components/JiraIssueWorkspace.tsx -inline src/renderer/src/components/LinearIssueWorkspace.tsx inline src/renderer/src/components/LinearItemDrawer.tsx inline src/renderer/src/components/NewWorkspaceComposerCard.tsx inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx -inline src/renderer/src/components/UpdateCard.tsx inline src/renderer/src/components/WorktreeJumpPalette.tsx inline src/renderer/src/components/activity/ActivityPrototypePage.tsx inline src/renderer/src/components/automations/AutomationsPage.tsx @@ -93,22 +85,17 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/EditorContent.tsx inline src/renderer/src/components/editor/IpynbViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx -inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx -inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx inline src/renderer/src/components/github-project/ProjectCell.tsx inline src/renderer/src/components/github-project/ProjectPicker.tsx inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx -inline src/renderer/src/components/linear-project-view-surfaces.tsx inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx inline src/renderer/src/components/onboarding/use-onboarding-flow.ts inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx inline src/renderer/src/components/right-sidebar/PortsPanel.tsx inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx inline src/renderer/src/components/settings/AccountsPane.tsx -inline src/renderer/src/components/settings/AgentsPane.tsx -inline src/renderer/src/components/settings/RepositoryHooksSection.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -126,7 +113,6 @@ inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts -inline src/renderer/src/hooks/useIpcEvents.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -145,12 +131,9 @@ inline src/renderer/src/store/slices/jira.ts inline src/renderer/src/store/slices/linear.ts inline src/renderer/src/store/slices/repos.ts inline src/renderer/src/store/slices/tabs.ts -inline src/renderer/src/store/slices/terminals.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts -inline src/renderer/src/web/web-preload-api.ts inline src/renderer/src/web/web-runtime-client.ts -inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts inline src/shared/commit-message-agent-spec.ts inline src/shared/constants.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2652c77eb20..7e8bc5b3b7e 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -14331,7 +14331,7 @@ "providers": ["local-daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": ["local-daemon", "ssh"], - "coverageNotes": "Deterministic production-boundary tests cover local daemon routing, pre-side-effect daemon attach fencing, SSH provider/relay incarnation checks, exact relay exit proof, renderer preservation, and guarded recovery. A real macOS multi-generation daemon journey and rendered Docker SSH reconnect journey passed. Paired headed/headless was blocked by missing web-client URL; targeted A/B update handoff, WSL, ConPTY, and physical Windows remain gaps.", + "coverageNotes": "Deterministic production-boundary tests cover persisted daemon owner identity, full-owner retirement CAS and rollback, local daemon routing, pre-side-effect daemon attach fencing, SSH provider/relay incarnation checks, exact relay exit proof, renderer preservation, and guarded recovery. A real macOS multi-generation daemon journey and rendered Docker SSH reconnect journey passed. Paired headed/headless was blocked by missing web-client URL; targeted A/B update handoff, WSL, ConPTY, and physical Windows remain gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-5269", "https://github.com/stablyai/orca/pull/16152" @@ -14342,6 +14342,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-authority.oracle.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-pty-router-owner-fencing.test.ts src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-incarnation-fence.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/ipc/pty-dead-owner-respawn.test.ts src/main/ipc/pty-controller-spawn-admission.test.ts src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/pane/stable-owner-retirement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-spawn.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/pty-handler-attach-replay.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts --reporter=dot", "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1", @@ -14362,6 +14363,7 @@ "src/main/ipc/pty-persisted-incarnation-repair.test.ts", "src/main/ipc/pty-dead-owner-respawn.test.ts", "src/main/ipc/pty-controller-spawn-admission.test.ts", + "src/main/ipc/pty/pane/stable-owner-retirement.test.ts", "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts", "src/main/providers/ssh-pty-provider-spawn.test.ts", "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", @@ -14398,6 +14400,13 @@ "relay absence and missing replay continuity remain unverifiable while exact matched process death is exited" ] }, + { + "file": "src/main/ipc/pty/pane/stable-owner-retirement.test.ts", + "assertions": [ + "retirement compares the full persisted owner tuple before deleting topology", + "a failed durable flush restores topology and preserves runtime owner state" + ] + }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts", "assertions": [ @@ -14478,6 +14487,15 @@ "durationSeconds": 4.07, "summary": "Six renderer files passed 65 liveness, typed-result, topology-preservation, and bounded recovery tests." }, + { + "date": "2026-08-25", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/pane/stable-owner-retirement.test.ts --reporter=dot", + "durationSeconds": 0.66, + "summary": "Three focused contracts passed full-owner retirement CAS, durable-flush rollback, and runtime-state preservation." + }, { "date": "2026-08-23", "runner": "local", @@ -14519,7 +14537,7 @@ "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." ], "knownGaps": [ - "Persisted daemon owner-incarnation identity is not yet implemented; the immediate guard uses exact in-process routes plus session incarnations and fails closed when the route is missing.", + "Direct SSH, relay, and paired-runtime bindings do not yet persist the complete owner-incarnation tuple; they remain session-incarnation fenced and fail closed when exact authority is unavailable.", "A current client can reattach a persisted-incarnation session through a surviving v31-v36 daemon and validates its returned incarnation, but those owners cannot reject an incarnation mismatch before attach-side replay or subscription changes.", "Pre-incarnation daemon events cannot authoritatively settle a saved session; anonymous exits remain unverifiable and may leave a dead legacy pane visible until the user closes it.", "A pre-fence SSH relay can ignore the optional expected incarnation and mutate delivery before a current client rejects its response; capability negotiation remains permanent-boundary work.", diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index 5f28a40c039..fc2482cbdb0 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 0a36eef0f56..04902579855 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -1,4 +1,4 @@ -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' type KnownStatus = { diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts index 1a122349ca2..c44a28f13d7 100644 --- a/src/main/agent-hooks/manual-compact-hook-stream.test.ts +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' -import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-authority-evidence.test.ts b/src/main/agent-hooks/server-authority-evidence.test.ts index 182eddada02..d6acf1886da 100644 --- a/src/main/agent-hooks/server-authority-evidence.test.ts +++ b/src/main/agent-hooks/server-authority-evidence.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-grok-discovery.test.ts b/src/main/agent-hooks/server-grok-discovery.test.ts index cea007c3d2f..f3ea95bc595 100644 --- a/src/main/agent-hooks/server-grok-discovery.test.ts +++ b/src/main/agent-hooks/server-grok-discovery.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import * as agentHookListener from '../../shared/agent-hook-listener' +import * as agentHookListener from '../../shared/agent-hook-listener/grok-result-discovery' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 622fc7d428b..55f2c6e1007 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -28,7 +28,101 @@ afterEach(() => { vi.restoreAllMocks() }) +async function postClaudeHook( + server: AgentHookServer, + payload: Record +): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) +} + describe('AgentHookServer listener replay', () => { + it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const order: string[] = [] + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const originalAssistantRetry = internal.scheduleAssistantMessageRetry.bind(server) + const originalCodexRetry = internal.scheduleCodexSubagentPoll.bind(server) + const assistantRetry = vi + .spyOn(internal, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(internal, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + const unsubscribeStatus = server.subscribeStatusChanges(() => order.push('status-change')) + server.setListener(() => { + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + order.push('main-listener') + }) + const unsubscribePlugin = server.subscribeEnrichedStatus(() => order.push('plugin-listener')) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'ordered' + }) + order.push('response') + expect(response.status).toBe(204) + expect(order).toEqual([ + 'status-change', + 'main-listener', + 'plugin-listener', + 'assistant-retry', + 'codex-retry', + 'response' + ]) + } finally { + unsubscribeStatus() + unsubscribePlugin() + assistantRetry.mockRestore() + codexRetry.mockRestore() + server.stop() + } + }) + + it('fails open after a throwing callback with cache retained and retries skipped', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const assistantRetry = vi.spyOn(internal, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(internal, 'scheduleCodexSubagentPoll') + server.setListener(() => { + throw new Error('listener failed') + }) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'cached before callback' + }) + expect(response.status).toBe(204) + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + assistantRetry.mockRestore() + codexRetry.mockRestore() + server.stop() + } + }) it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index c7795fb8069..6c96d9c3bb8 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { createShedSubagentsField } from '../../shared/agent-hook-relay' import { buildBody, PANE } from './server.test-fixtures' diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f69ffea2e4c..e447be40262 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -13,33 +13,45 @@ import { clearAllListenerCaches, clearPaneCacheState, paneHasStateClaims, - clearClaudeAnsweredQuestionWait, createHookListenerState, - getEndpointFileName, - hasCodexTranscriptSubagents, - hasPendingAgentResultText, - HOOK_REQUEST_SLOWLORIS_MS, - isNewTurnEvent, - markClaudeLeadTurnInterrupted, - markCodexLeadTurnInterrupted, - MAX_PANE_KEY_LEN, movePaneCacheState, - normalizeClaudePromptId, - normalizeHookPayload, - parseFormEncodedBody, - readRequestBody, - reapRestoredClaudeSubagentsForDeadPane, - reconcileRemoteCodexState, - resolveHookSource, - preparePendingGrokResultDiscovery, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots, - seedCodexStateFromSnapshot, - warnOnHookEnvOrVersionMismatch, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { + clearClaudeAnsweredQuestionWait, + markClaudeLeadTurnInterrupted, + reapRestoredClaudeSubagentsForDeadPane, + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../shared/agent-hook-listener/providers/claude-roster-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../../shared/agent-hook-listener/endpoint-publication' +import { + hasCodexTranscriptSubagents, + markCodexLeadTurnInterrupted, + reconcileRemoteCodexState, + seedCodexStateFromSnapshot +} from '../../shared/agent-hook-listener/providers/codex-state' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../shared/agent-hook-listener/grok-result-discovery' +import { + HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../shared/agent-hook-listener/listener-limits' +import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { + parseFormEncodedBody, + readRequestBody +} from '../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { canAcceptClaudeCompactCompletion, isClaudeCompactCompletionConsumed, diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index f5a54aa1e15..eef03297279 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, movePaneCacheState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { applyManagedHooks, CLAUDE_EVENTS } from './hook-settings' diff --git a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts index 409d3ff9e68..67f5bfc377a 100644 --- a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts +++ b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts @@ -216,6 +216,15 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { snapshot: null } as never) const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 36 }) + ;( + legacy as unknown as { + lastAuthenticatedIdentity: { pid: number; startedAtMs: number; launchNonce: string } + } + ).lastAuthenticatedIdentity = { + pid: 42, + startedAtMs: 1, + launchNonce: 'legacy-owner' + } vi.spyOn( (legacy as unknown as { client: DaemonClient }).client, 'ensureConnected' @@ -230,7 +239,15 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expectedIncarnationId: 'expected-incarnation', expectedIncarnationIsAuthoritative: true }) - ).resolves.toMatchObject({ id: 'legacy-owner-session', isReattach: true }) + ).resolves.toMatchObject({ + id: 'legacy-owner-session', + isReattach: true, + ownerIdentity: { + ownerIncarnationId: expect.any(String), + sessionIncarnationId: 'expected-incarnation', + protocolVersion: 36 + } + }) expect(request).toHaveBeenCalledWith( 'createOrAttach', expect.objectContaining({ attachOnly: true }) diff --git a/src/main/git/repo-api-parity.test.ts b/src/main/git/repo-api-parity.test.ts new file mode 100644 index 00000000000..9143b7bf65f --- /dev/null +++ b/src/main/git/repo-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import * as repo from './repo' + +describe('repo public API parity', () => { + it('keeps the historical runtime export surface', () => { + expect(Object.keys(repo).sort()).toEqual( + [ + 'DEFAULT_BASE_REF_PROBES', + 'buildSearchBaseRefsArgv', + 'getBaseRefDefault', + 'getBranchConflictKind', + 'getDefaultBaseRef', + 'getDefaultRemote', + 'getGitRepoRoot', + 'getLinkedWorktreeMainRepoRoot', + 'getRecentDriftSubjects', + 'getRemoteCommitUrl', + 'getRemoteCount', + 'getRemoteDrift', + 'getRemoteFileUrl', + 'getRemoteUrl', + 'getRepoName', + 'isForEachRefExcludeUnsupportedError', + 'isGitRepo', + 'mergeBaseRefSearchResultGroups', + 'normalizeGitRepoRootForInputPath', + 'normalizeRefSearchQuery', + 'parseAndFilterSearchRefDetails', + 'parseRemoteCount', + 'resolveDefaultBaseRefViaExec', + 'resolveDefaultBaseRefWithLocalGit', + 'searchBaseRefDetails', + 'searchBaseRefs' + ].sort() + ) + }) +}) diff --git a/src/main/git/repo-base-ref-search.ts b/src/main/git/repo-base-ref-search.ts new file mode 100644 index 00000000000..5ea01a7ec1c --- /dev/null +++ b/src/main/git/repo-base-ref-search.ts @@ -0,0 +1,240 @@ +import type { BaseRefSearchResult } from '../../shared/repo-types' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' +import { getLocalGitCapabilityCache } from './git-capability-state' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 +const REF_SEARCH_LEGACY_HEADROOM = 100 + +type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' + +function getRefSearchTokens(normalizedQuery: string): string[] { + return normalizedQuery.split('/').filter((token) => token.length > 0) +} + +function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER + return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM +} + +/** Build the bounded `for-each-ref` argv shared by local and remote searches. */ +export function buildSearchBaseRefsArgv( + normalizedQuery: string, + limit: number, + options: { + excludeRemoteHead?: boolean + remoteNames?: readonly string[] + patternGroup?: RefSearchPatternGroup + } = {} +): string[] { + const excludeRemoteHead = options.excludeRemoteHead ?? true + const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) + const base = [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + ...(excludeRemoteHead ? ['--exclude=refs/remotes/**/HEAD'] : []), + `--count=${candidateCount}` + ] + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length <= 1) { + const query = tokens[0] ?? '' + return [ + ...base, + `refs/heads/**/*${query}*`, + `refs/heads/**/*${query}*/**`, + `refs/remotes/**/*${query}*`, + `refs/remotes/**/*${query}*/**` + ] + } + + const segmented = tokens.map((token) => `*${token}*`).join('/') + const substringQuery = tokens.join('/') + const remoteBranchRootPatterns = + options.remoteNames && options.remoteNames.length > 0 + ? options.remoteNames.flatMap((remote) => [ + `refs/remotes/${remote}/${substringQuery}*`, + `refs/remotes/${remote}/${substringQuery}*/**` + ]) + : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] + const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const branchRootPatterns = [ + `refs/heads/${substringQuery}*`, + `refs/heads/${substringQuery}*/**`, + ...remoteBranchRootPatterns + ] + const patterns = + options.patternGroup === 'segmented' + ? segmentedPatterns + : options.patternGroup === 'branchRoot' + ? branchRootPatterns + : [...segmentedPatterns, ...branchRootPatterns] + return [...base, ...patterns] +} + +async function runSearchBaseRefsGit( + path: string, + normalizedQuery: string, + limit: number, + options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } +): Promise<{ stdout: string }> { + return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( + 'for-each-ref-exclude', + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + isForEachRefExcludeUnsupportedError + ) +} + +export function mergeBaseRefSearchResultGroups( + groups: readonly BaseRefSearchResult[][], + limit: number +): BaseRefSearchResult[] { + const seen = new Set() + const merged: BaseRefSearchResult[] = [] + const maxLength = Math.max(0, ...groups.map((group) => group.length)) + for (let index = 0; index < maxLength && merged.length < limit; index += 1) { + for (const group of groups) { + const entry = group[index] + if (!entry || seen.has(entry.refName)) { + continue + } + seen.add(entry.refName) + merged.push(entry) + if (merged.length >= limit) { + break + } + } + } + return merged +} + +export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { + return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) +} + +export async function searchBaseRefDetails( + path: string, + query: string, + limit = 25 +): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + + try { + const remotes = await listRemoteNames(path) + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length > 1) { + const results = await Promise.all([ + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'segmented' + }), + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'branchRoot' + }) + ]) + return mergeBaseRefSearchResultGroups( + results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), + limit + ) + } + + const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes + }) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + } catch (err) { + console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) + return [] + } +} + +export async function listRemoteNames( + path: string, + options: LocalGitExecOptions = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export function parseAndFilterSearchRefDetails( + stdout: string, + limit: number, + remotes: string[] = [] +): BaseRefSearchResult[] { + const seen = new Set() + const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const nul = line.indexOf('\0') + return nul === -1 ? null : { full: line.slice(0, nul), short: line.slice(nul + 1) } + }) + .filter((entry): entry is { full: string; short: string } => entry !== null) + .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) + .filter(({ short }) => { + if (seen.has(short)) { + return false + } + seen.add(short) + return true + }) + .map(({ full, short }) => ({ + refName: short, + localBranchName: resolveLocalBranchName(full, short, sortedRemotes) + })) + .slice(0, Math.max(0, limit)) +} + +export function resolveLocalBranchName( + fullRef: string, + shortRef: string, + remotes: string[] +): string { + const remoteRefPrefix = 'refs/remotes/' + if (!fullRef.startsWith(remoteRefPrefix)) { + return shortRef + } + const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) + const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) + if (remote) { + return remoteAndBranch.slice(remote.length + 1) + } + return remoteAndBranch.split('/').slice(1).join('/') || shortRef +} + +export function normalizeRefSearchQuery(query: string): string { + return query.trim().replace(/[*?[\]\\]/g, '') +} diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts new file mode 100644 index 00000000000..dbadf254d11 --- /dev/null +++ b/src/main/git/repo-branch-conflict.ts @@ -0,0 +1,59 @@ +import { listRemoteNames, resolveLocalBranchName } from './repo-base-ref-search' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +export type BranchConflictKind = 'local' | 'remote' + +async function hasGitRefAsync( + path: string, + ref: string, + options: LocalGitExecOptions = {} +): Promise { + try { + await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) + return true + } catch { + return false + } +} + +export async function getBranchConflictKind( + path: string, + branchName: string, + allowedBaseRef?: string, + options: LocalGitExecOptions = {} +): Promise { + if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { + return 'local' + } + + try { + const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) + const { stdout } = await gitExecFileAsync( + ['for-each-ref', '--format=%(refname)', 'refs/remotes'], + gitExecOptions(path, options) + ) + const hasRemoteConflict = stdout.split('\n').some((ref) => { + const trimmed = ref.trim() + if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { + return false + } + const shortRef = trimmed.replace(/^refs\/remotes\//, '') + return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName + }) + + return hasRemoteConflict ? 'remote' : null + } catch { + return null + } +} + +function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { + if (!allowedBaseRef) { + return false + } + const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') + ? allowedBaseRef + : `refs/remotes/${allowedBaseRef}` + return refName === normalizedAllowedRef +} diff --git a/src/main/git/repo-default-base-ref.ts b/src/main/git/repo-default-base-ref.ts new file mode 100644 index 00000000000..59adaeed512 --- /dev/null +++ b/src/main/git/repo-default-base-ref.ts @@ -0,0 +1,133 @@ +import { gitExecFileAsync, gitExecFileSync } from './runner' + +export type LocalGitExecOptions = { + wslDistro?: string +} + +export type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + +export function gitExecOptions( + cwd: string, + options: LocalGitExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} + +export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ + { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, + { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, + { ref: 'refs/heads/main', returnAs: 'main' }, + { ref: 'refs/heads/master', returnAs: 'master' } +] + +async function resolveDefaultBaseRefFromProbes( + hasRef: (ref: string) => Promise +): Promise { + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (await hasRef(ref)) { + return returnAs + } + } + return null +} + +function hasGitRef(path: string, ref: string): boolean { + try { + gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path }) + return true + } catch { + return false + } +} + +function gitRefToDefaultBaseRef(ref: string): string { + return ref.replace(/^refs\/remotes\//, '') +} + +function getVerifiedOriginHeadBaseRef(path: string): string | null { + try { + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path + }).trim() + return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null + } catch { + return null + } +} + +/** Resolve the default base ref without inventing a fallback branch. */ +export function getDefaultBaseRef(path: string): string | null { + const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) + if (originHeadBaseRef) { + return originHeadBaseRef + } + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (hasGitRef(path, ref)) { + return returnAs + } + } + return null +} + +export async function getBaseRefDefault( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return getDefaultBaseRefAsync(path, options) +} + +export type GitExec = (argv: string[]) => Promise<{ stdout: string }> + +async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { + try { + await exec(['rev-parse', '--verify', '--quiet', ref]) + return true + } catch { + return false + } +} + +async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { + try { + const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + const ref = stdout.trim() + if (!ref || !(await hasGitRefViaExec(exec, ref))) { + return null + } + return gitRefToDefaultBaseRef(ref) + } catch { + return null + } +} + +/** Resolve the same default-base ordering through a host-owned Git executor. */ +export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { + const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) + if (originHeadBaseRef) { + return originHeadBaseRef + } + return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) +} + +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} + +export function getDefaultBaseRefAsync( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) +} diff --git a/src/main/git/repo-default-remote.test.ts b/src/main/git/repo-default-remote.test.ts new file mode 100644 index 00000000000..a0764598fb8 --- /dev/null +++ b/src/main/git/repo-default-remote.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { getDefaultRemote } from './repo' + +describe('getDefaultRemote', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('prefers the configured remote for the resolved default branch', async () => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n' } + } + if (argv[0] === 'rev-parse') { + return { stdout: 'abc123\n' } + } + if (argv[0] === 'config') { + return { stdout: 'upstream\n' } + } + throw new Error('unexpected command') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe('upstream') + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['config', '--get', 'branch.main.remote'], + { cwd: '/repo' } + ) + }) + + it.each([ + ['origin\nupstream\n', 'origin'], + ['company\n', 'company'] + ])('falls back from unresolved defaults for remotes %j', async (stdout, expected) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe(expected) + }) + + it.each([ + ['', 'Repo has no configured git remotes.'], + [ + 'upstream\nfork\n', + 'Repo has multiple remotes (upstream, fork) and no default is configured. Set branch..remote.' + ] + ])('preserves the exact ambiguous-remote error for %j', async (stdout, message) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).rejects.toThrow(message) + }) + + it('normalizes a non-Error remote rejection', async () => { + for (let probe = 0; probe < 5; probe += 1) { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('missing ref')) + } + gitExecFileAsyncMock.mockRejectedValueOnce('transport failed') + + await expect(getDefaultRemote('/repo')).rejects.toThrow( + 'Failed to resolve default remote for repo.' + ) + }) +}) diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts new file mode 100644 index 00000000000..2f67005a4e7 --- /dev/null +++ b/src/main/git/repo-detection.ts @@ -0,0 +1,141 @@ +import { existsSync, statSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { scanGitMarkerSync, resolveRealPathSync } from './repo-git-marker-scan' +import { gitExecFileSync } from './runner' + +type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' + +let warnedMarkerFallbackThisSession = false + +/** Check if a path is a valid git repository (regular or bare). */ +export function isGitRepo(path: string): boolean { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return false + } + } catch { + return false + } + + const gitProbeResult = probeGitRepo(path) + if (gitProbeResult === 'repo') { + return true + } + if (gitProbeResult === 'not-repo') { + return false + } + + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { + warnedMarkerFallbackThisSession = true + console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { + path + }) + } + return markerScan.status === 'valid' +} + +/** Only a clean pair of negative Git answers is a definitive non-repo. */ +function probeGitRepo(path: string): GitRepoProbeResult { + let sawFailure = false + + try { + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + return 'repo' + } + if (insideWorkTree !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + try { + const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { + cwd: path + }).trim() + if (bareRepo === 'true') { + return 'repo' + } + if (bareRepo !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + return sawFailure ? 'indeterminate' : 'not-repo' +} + +export function getGitRepoRoot(path: string): string { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return path + } + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { + cwd: path + }).trim() + return normalizeGitRepoRootForInputPath(path, root) + } + } catch { + // Fall through to preserving the original path. + } + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid') { + return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) + } + return path +} + +function canonicalizeGitDirPath(path: string): string { + return resolveRealPathSync(path) ?? path +} + +/** Return the main-checkout path only when `path` is a linked worktree. */ +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return null + } + if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { + return null + } + const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { + cwd: path + }) + .split('\n') + .map((line) => line.trim()) + if (!gitDir || !commonDir) { + return null + } + const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) + if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { + return null + } + if (basename(absoluteCommonDir) !== '.git') { + return null + } + return getGitRepoRoot(dirname(absoluteCommonDir)) + } catch { + return null + } +} + +export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { + const inputWsl = parseWslUncPath(inputPath) + if (inputWsl && rootPath.startsWith('/')) { + // Why: persist the UNC root so later Git calls keep routing through the WSL runner. + return toWindowsWslPath(rootPath, inputWsl.distro) + } + return normalizeRuntimePathSeparators(rootPath) +} diff --git a/src/main/git/repo-git-marker-scan.ts b/src/main/git/repo-git-marker-scan.ts new file mode 100644 index 00000000000..5442d74d391 --- /dev/null +++ b/src/main/git/repo-git-marker-scan.ts @@ -0,0 +1,251 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +export type GitMarkerScanResult = + | { status: 'valid'; rootPath: string } + | { status: 'absent' | 'invalid' } + +/** Filesystem fallback for genuine Git metadata when git cannot answer cleanly. */ +export function scanGitMarkerSync(path: string): GitMarkerScanResult { + const realPath = resolveRealPathSync(path) + if (realPath && realPath !== path) { + const lexicalScan = scanGitMarkerAncestorsSync(path) + const realPathScan = scanGitMarkerAncestorsSync(realPath) + if ( + lexicalScan.status === 'valid' && + realPathScan.status === 'valid' && + pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) + ) { + // Why: preserve lexical spellings, but let a cross-repo symlink bind to its real target. + return lexicalScan + } + return realPathScan + } + return scanGitMarkerAncestorsSync(path) +} + +export function resolveRealPathSync(path: string): string | null { + try { + return realpathSync.native(path) + } catch { + try { + return realpathSync(path) + } catch { + return null + } + } +} + +function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { + for (const candidate of ancestorDirectories(path)) { + if (!isInsideDotGitMarker(candidate, path)) { + const worktreeMarker = scanWorktreeMarkerSync(candidate) + if (worktreeMarker.status !== 'absent') { + return worktreeMarker + } + } + if (hasValidBareRepoMarkerSync(candidate)) { + return { status: 'valid', rootPath: candidate } + } + } + return { status: 'absent' } +} + +function ancestorDirectories(path: string): string[] { + const directories: string[] = [] + let current = path + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + return directories + } + current = parent + } +} + +function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + return false + } + const firstSegment = relativePath.split(/[\\/]+/)[0] + if (firstSegment === '.git') { + return true + } + if (firstSegment.toLowerCase() !== '.git') { + return false + } + return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) +} + +function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { + try { + const leftStat = statSync(leftPath) + const rightStat = statSync(rightPath) + if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { + return true + } + const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) + const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) + return process.platform === 'win32' + ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() + : leftRealPath === rightRealPath + } catch { + return false + } +} + +function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { + const dotGit = join(worktreePath, '.git') + let marker: ReturnType + try { + marker = statSync(dotGit) + } catch { + return { status: 'absent' } + } + + if (marker.isDirectory()) { + return hasValidGitDirectorySync(dotGit) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + if (marker.isFile()) { + let gitDir: string | null + try { + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + } catch { + return { status: 'invalid' } + } + return gitDir !== null && hasValidGitDirectorySync(gitDir) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + return { status: 'invalid' } +} + +function parseGitdirFile(basePath: string, content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) + if (!match) { + return null + } + return resolveGitMetadataPath(basePath, match[1]) +} + +function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + const baseWsl = parseWslUncPath(basePath) + if (baseWsl && value.startsWith('/')) { + return toWindowsWslPath(value, baseWsl.distro) + } + return isAbsolute(value) ? value : resolve(basePath, value) +} + +function hasValidGitDirectorySync(gitDir: string): boolean { + return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) +} + +function hasValidCommonGitDirectorySync(gitDir: string): boolean { + try { + return ( + statSync(join(gitDir, 'HEAD')).isFile() && + statSync(join(gitDir, 'objects')).isDirectory() && + statSync(join(gitDir, 'refs')).isDirectory() + ) + } catch { + return false + } +} + +function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { + try { + if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { + return false + } + const commonDir = resolveGitMetadataPath( + gitDir, + readFileSync(join(gitDir, 'commondir'), 'utf8') + ) + return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) + } catch { + return false + } +} + +function hasValidBareRepoMarkerSync(path: string): boolean { + return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) +} + +function gitConfigDeclaresNonBare(gitDir: string): boolean { + try { + const config = readFileSync(join(gitDir, 'config'), 'utf8') + let inCoreSection = false + for (const line of config.split(/\r?\n/)) { + const section = line.match(/^\s*\[([^\]]+)\]/) + if (section) { + inCoreSection = section[1].trim().toLowerCase() === 'core' + continue + } + const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) + if (inCoreSection && bare) { + return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) + } + } + return false + } catch { + return false + } +} + +function normalizeGitConfigValue(value: string): string { + const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') + if ( + unescaped.length >= 2 && + ((unescaped.startsWith('"') && unescaped.endsWith('"')) || + (unescaped.startsWith("'") && unescaped.endsWith("'"))) + ) { + return unescaped.slice(1, -1) + } + return unescaped +} + +function stripGitConfigInlineComment(value: string): string { + let quote: '"' | "'" | null = null + let escaped = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' || char === ';') { + return value.slice(0, i) + } + } + return value +} + +function isGitBooleanFalse(value: string): boolean { + return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 50d8a278be2..fcf5167f907 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,542 +1,57 @@ -/* oxlint-disable max-lines */ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { gitExecFileSync, gitExecFileAsync } from './runner' -import type { BaseRefSearchResult } from '../../shared/repo-types' +import { basename } from 'node:path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' -import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' -import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { + DEFAULT_BASE_REF_PROBE_TIMEOUT_MS, + getDefaultBaseRef, + getDefaultBaseRefAsync, + gitExecOptions, + type LocalGitExecOptions +} from './repo-default-base-ref' +import { gitExecFileAsync, gitExecFileSync } from './runner' -type LocalGitExecOptions = { - wslDistro?: string -} - -type LocalDefaultBaseRefGitOptions = { - cwd: string - wslDistro?: string -} - -const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 - -type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' -type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } - -function gitExecOptions( - cwd: string, - options: LocalGitExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } -} - -/** - * Ordered probe list for a repo's default base ref when no origin/HEAD symbolic-ref is set. - * `returnAs` is the short-name format the UI expects (as `for-each-ref --format=%(refname:short)` renders it). - * Shared local/SSH so both resolve identical defaults. - */ -export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ - { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, - { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, - { ref: 'refs/heads/main', returnAs: 'main' }, - { ref: 'refs/heads/master', returnAs: 'master' } -] - -/** - * Walk DEFAULT_BASE_REF_PROBES in order, returning the first ref `hasRef` confirms, or null. - * Abstracts the existence test so local and SSH paths share one authoritative probe ordering. - */ -async function resolveDefaultBaseRefFromProbes( - hasRef: (ref: string) => Promise -): Promise { - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (await hasRef(ref)) { - return returnAs - } - } - return null -} - -/** Check if a path is a valid git repository (regular or bare). */ -export function isGitRepo(path: string): boolean { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return false - } - } catch { - return false - } - - // Ask git directly first — authoritative for work trees, linked worktrees, submodules, and bare repos. - const gitProbeResult = probeGitRepo(path) - if (gitProbeResult === 'repo') { - return true - } - if (gitProbeResult === 'not-repo') { - return false - } - - // Why: rev-parse can fail for reasons unrelated to repo-ness (spawn hiccup, config error); fall back to a - // validated `.git` marker instead of downgrading a real repo to a plain folder (the spurious "Open as Folder" bug). - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { - // Why: warn once per session; the folder scanner calls isGitRepo for many paths and would otherwise flood logs. - warnedMarkerFallbackThisSession = true - console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { - path - }) - } - return markerScan.status === 'valid' -} - -let warnedMarkerFallbackThisSession = false - -/** - * Tri-state git probe: only a clean pair of negative answers is a definitive - * non-repo. Spawn/config failures stay indeterminate so marker fallback can run. - */ -function probeGitRepo(path: string): GitRepoProbeResult { - let sawFailure = false - - try { - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - return 'repo' - } - if (insideWorkTree !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - try { - const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { - cwd: path - }).trim() - if (bareRepo === 'true') { - return 'repo' - } - if (bareRepo !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - return sawFailure ? 'indeterminate' : 'not-repo' -} - -export function getGitRepoRoot(path: string): string { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return path - } - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { - cwd: path - }).trim() - return normalizeGitRepoRootForInputPath(path, root) - } - } catch { - // Fall through to preserving the original path. - } - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid') { - return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) - } - return path -} - -function canonicalizeGitDirPath(path: string): string { - return resolveRealPathSync(path) ?? path -} - -/** - * Main-checkout path when `path` is a *linked* worktree, else null (main worktree, bare repo, - * non-repo, or any git failure). A linked worktree's `--git-dir` is `/worktrees/` - * while the main worktree's equals `--git-common-dir`; comparing the two from one invocation is - * git's own canonical test and avoids symlink-canonicalization mismatches. Baseline-safe: both - * flags long predate Git 2.25, and a relative answer resolves against `path` as old Git reports it. - */ -export function getLinkedWorktreeMainRepoRoot(path: string): string | null { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return null - } - if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { - return null - } - const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { - cwd: path - }) - .split('\n') - .map((line) => line.trim()) - if (!gitDir || !commonDir) { - return null - } - // Why realpath both: git answers one flag absolutely (already symlink-resolved) and the other - // relative to cwd, so a repo under a symlinked root (macOS /var -> /private/var) compares - // unequal on raw strings and a main checkout gets misread as a linked worktree. - const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) - if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { - return null - } - // A bare/separate git dir has no adjacent working checkout to point at. - if (basename(absoluteCommonDir) !== '.git') { - return null - } - // Re-resolve through getGitRepoRoot so the returned path matches the canonical form - // add-project stores for the main checkout (symlinks resolved the way git reports them). - return getGitRepoRoot(dirname(absoluteCommonDir)) - } catch { - return null - } -} - -export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { - const inputWsl = parseWslUncPath(inputPath) - if (inputWsl && rootPath.startsWith('/')) { - // Why: WSL git reports Linux-native roots; persist the UNC path so later git calls keep routing through the WSL runner. - return toWindowsWslPath(rootPath, inputWsl.distro) - } - return normalizeRuntimePathSeparators(rootPath) -} - -/** - * Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to - * reject a garbage `.git` file (validation from 18ed7b27d): accepts a `.git` dir/file with real gitdir shape - * or a bare-repo root (HEAD + objects/ + refs/, not a worktree admin dir). - */ -function scanGitMarkerSync(path: string): GitMarkerScanResult { - const realPath = resolveRealPathSync(path) - if (realPath && realPath !== path) { - const lexicalScan = scanGitMarkerAncestorsSync(path) - const realPathScan = scanGitMarkerAncestorsSync(realPath) - if ( - lexicalScan.status === 'valid' && - realPathScan.status === 'valid' && - pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) - ) { - // Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git. - return lexicalScan - } - return realPathScan - } - return scanGitMarkerAncestorsSync(path) -} - -function resolveRealPathSync(path: string): string | null { - try { - return realpathSync.native(path) - } catch { - try { - return realpathSync(path) - } catch { - return null - } - } -} - -function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { - for (const candidate of ancestorDirectories(path)) { - if (!isInsideDotGitMarker(candidate, path)) { - const worktreeMarker = scanWorktreeMarkerSync(candidate) - if (worktreeMarker.status !== 'absent') { - return worktreeMarker - } - } - if (hasValidBareRepoMarkerSync(candidate)) { - return { status: 'valid', rootPath: candidate } - } - } - return { status: 'absent' } -} - -function ancestorDirectories(path: string): string[] { - const directories: string[] = [] - let current = path - while (true) { - directories.push(current) - const parent = dirname(current) - if (parent === current) { - return directories - } - current = parent - } -} - -function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { - const relativePath = relative(rootPath, targetPath) - if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { - return false - } - const firstSegment = relativePath.split(/[\\/]+/)[0] - if (firstSegment === '.git') { - return true - } - if (firstSegment.toLowerCase() !== '.git') { - return false - } - return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) -} - -function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { - try { - const leftStat = statSync(leftPath) - const rightStat = statSync(rightPath) - if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { - return true - } - const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) - const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) - return process.platform === 'win32' - ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() - : leftRealPath === rightRealPath - } catch { - return false - } -} - -function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { - const dotGit = join(worktreePath, '.git') - let marker: ReturnType - try { - marker = statSync(dotGit) - } catch { - return { status: 'absent' } - } - - if (marker.isDirectory()) { - return hasValidGitDirectorySync(dotGit) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - if (marker.isFile()) { - let gitDir: string | null - try { - gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) - } catch { - return { status: 'invalid' } - } - return gitDir !== null && hasValidGitDirectorySync(gitDir) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - return { status: 'invalid' } -} - -function parseGitdirFile(basePath: string, content: string): string | null { - const firstLine = content.split(/\r?\n/, 1)[0] ?? '' - const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) - if (!match) { - return null - } - return resolveGitMetadataPath(basePath, match[1]) -} - -function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { - const value = rawPath.trim() - if (!value) { - return null - } - const baseWsl = parseWslUncPath(basePath) - if (baseWsl && value.startsWith('/')) { - return toWindowsWslPath(value, baseWsl.distro) - } - return isAbsolute(value) ? value : resolve(basePath, value) -} - -function hasValidGitDirectorySync(gitDir: string): boolean { - return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) -} - -function hasValidCommonGitDirectorySync(gitDir: string): boolean { - try { - return ( - statSync(join(gitDir, 'HEAD')).isFile() && - statSync(join(gitDir, 'objects')).isDirectory() && - statSync(join(gitDir, 'refs')).isDirectory() - ) - } catch { - return false - } -} - -function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { - try { - if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { - return false - } - const commonDir = resolveGitMetadataPath( - gitDir, - readFileSync(join(gitDir, 'commondir'), 'utf8') - ) - return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) - } catch { - return false - } -} - -function hasValidBareRepoMarkerSync(path: string): boolean { - return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) -} - -function gitConfigDeclaresNonBare(gitDir: string): boolean { - try { - const config = readFileSync(join(gitDir, 'config'), 'utf8') - let inCoreSection = false - for (const line of config.split(/\r?\n/)) { - const section = line.match(/^\s*\[([^\]]+)\]/) - if (section) { - inCoreSection = section[1].trim().toLowerCase() === 'core' - continue - } - const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) - if (inCoreSection && bare) { - return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) - } - } - return false - } catch { - return false - } -} - -function normalizeGitConfigValue(value: string): string { - const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') - if ( - unescaped.length >= 2 && - ((unescaped.startsWith('"') && unescaped.endsWith('"')) || - (unescaped.startsWith("'") && unescaped.endsWith("'"))) - ) { - return unescaped.slice(1, -1) - } - return unescaped -} - -function stripGitConfigInlineComment(value: string): string { - let quote: '"' | "'" | null = null - let escaped = false - for (let i = 0; i < value.length; i++) { - const char = value[i] - if (escaped) { - escaped = false - continue - } - if (char === '\\') { - escaped = true - continue - } - if (quote) { - if (char === quote) { - quote = null - } - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (char === '#' || char === ';') { - return value.slice(0, i) - } - } - return value -} - -function isGitBooleanFalse(value: string): boolean { - return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) -} +export { + isGitRepo, + getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, + normalizeGitRepoRootForInputPath +} from './repo-detection' +export { + DEFAULT_BASE_REF_PROBES, + getDefaultBaseRef, + getBaseRefDefault, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from './repo-default-base-ref' +export type { GitExec } from './repo-default-base-ref' +export { + buildSearchBaseRefsArgv, + mergeBaseRefSearchResultGroups, + searchBaseRefs, + searchBaseRefDetails, + parseAndFilterSearchRefDetails, + normalizeRefSearchQuery +} from './repo-base-ref-search' +export { getBranchConflictKind } from './repo-branch-conflict' +export type { BranchConflictKind } from './repo-branch-conflict' +export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' /** Get a human-readable name for the repo from its path. */ export function getRepoName(path: string): string { const name = basename(path) - // Strip .git suffix from bare repos return name.endsWith('.git') ? name.slice(0, -4) : name } /** Get the remote origin URL, or null if not set. */ export function getRemoteUrl(path: string): string | null { try { - return getRemoteUrlByName(path, 'origin') + return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim() } catch { return null } } -function getRemoteUrlByName(path: string, remote: string): string { - return gitExecFileSync(['remote', 'get-url', remote], { - cwd: path - }).trim() -} - -function hasGitRef(path: string, ref: string): boolean { - try { - gitExecFileSync(['rev-parse', '--verify', ref], { - cwd: path - }) - return true - } catch { - return false - } -} - -function gitRefToDefaultBaseRef(ref: string): string { - return ref.replace(/^refs\/remotes\//, '') -} - -function getVerifiedOriginHeadBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - - // Why: origin/HEAD may survive a default-branch rename pointing at a deleted ref; verify before trusting it. - return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null - } catch { - return null - } -} - -/** - * Resolve the default base ref for new worktrees, preferring the remote primary over a stale local branch. - * Returns null when nothing resolves (rather than a hardcoded `origin/main`) so callers fail loudly or degrade. - */ -export function getDefaultBaseRef(path: string): string | null { - const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) - if (originHeadBaseRef) { - return originHeadBaseRef - } - - // Why: walk the shared DEFAULT_BASE_REF_PROBES so sync and async/SSH paths can't drift on ref order. - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (hasGitRef(path, ref)) { - return returnAs - } - } - return null -} - -export async function getBaseRefDefault( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return getDefaultBaseRefAsync(path, options) -} - -/** - * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. - * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). - * Async because this runs before every orchestration dispatch on Electron main. - */ +/** Return the symmetric commit delta, or null when Git cannot prove it. */ export async function getRemoteDrift( repoPath: string, localRef: string, @@ -561,10 +76,7 @@ export async function getRemoteDrift( } } -/** - * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. - * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. - */ +/** Return recent subjects on the remote side of the local/remote delta. */ export async function getRecentDriftSubjects( repoPath: string, localRef: string, @@ -580,238 +92,34 @@ export async function getRecentDriftSubjects( timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS } ) - return stdout.split('\n').filter((s) => s.trim().length > 0) + return stdout.split('\n').filter((subject) => subject.trim().length > 0) } catch { return [] } } -/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */ +/** Parse `git remote` stdout into a remote count. */ export function parseRemoteCount(stdout: string): number { return stdout.split('\n').filter((line) => line.trim().length > 0).length } -/** Count configured remotes via `git remote`; returns 0 on error (callers read 0 as "unknown / no hint"). */ +/** Count configured remotes; zero means either none or unavailable. */ export async function getRemoteCount(path: string): Promise { try { const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) return parseRemoteCount(stdout) } catch (err) { - // Why: log so a missing multi-remote hint is debuggable; callers still treat 0 as "unknown". console.warn('[getRemoteCount] git remote failed', { path, err }) return 0 } } -/** Callback shape for a git exec function that yields stdout. */ -export type GitExec = (argv: string[]) => Promise<{ stdout: string }> - -async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { - try { - await exec(['rev-parse', '--verify', '--quiet', ref]) - return true - } catch { - return false - } -} - -async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { - try { - const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - const ref = stdout.trim() - if (!ref || !(await hasGitRefViaExec(exec, ref))) { - return null - } - return gitRefToDefaultBaseRef(ref) - } catch { - return null - } -} - -/** - * Resolve the default base ref via a git exec callback: prefer origin/HEAD's symbolic-ref target, - * else fall back to DEFAULT_BASE_REF_PROBES. Shared local/SSH so both transports agree. - * - * Why swallow symbolic-ref's error: a non-zero exit is the expected "origin/HEAD unset" signal, not a failure. - */ -export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) - if (originHeadBaseRef) { - return originHeadBaseRef - } - return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) -} - -export function resolveDefaultBaseRefWithLocalGit( - options: LocalDefaultBaseRefGitOptions -): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, { - ...options, - // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. - timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS - }) - ) -} - -async function getDefaultBaseRefAsync( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) -} - -/** - * Build the argv for `git for-each-ref` used by ref search, given an already-normalized query. - * - * Why: glob every remote (`refs/remotes/*\/*`), not just origin, so fork workflows can find branches like - * `upstream/main` — see docs/upstream-base-ref-design.md. Shared with the SSH relay path so argv can't diverge. - */ -const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 -const REF_SEARCH_LEGACY_HEADROOM = 100 - -type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' - -function getRefSearchTokens(normalizedQuery: string): string[] { - return normalizedQuery.split('/').filter((t) => t.length > 0) -} - -function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER - return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM -} - -export function buildSearchBaseRefsArgv( - normalizedQuery: string, - limit: number, - options: { - excludeRemoteHead?: boolean - remoteNames?: readonly string[] - patternGroup?: RefSearchPatternGroup - } = {} -): string[] { - const excludeRemoteHead = options.excludeRemoteHead ?? true - const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) - const base = [ - 'for-each-ref', - '--format=%(refname)%00%(refname:short)', - '--sort=-committerdate', - ...(excludeRemoteHead - ? [ - // Why: exclude remote HEAD pseudo-refs before --count so the candidate window holds displayable refs. - '--exclude=refs/remotes/**/HEAD' - ] - : []), - // Why: cap git output so broad globs don't overflow execFile/SSH buffers in very large repos. - `--count=${candidateCount}` - ] - // Why: split on `/` so display-format queries route each token to one ref segment; filter empties from stray slashes. - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length <= 1) { - const q = tokens[0] ?? '' - // Why `**` not `*`: fnmatch `*` can't cross `/`, so match slash-named branches at both leaf and ancestor segments. - return [ - ...base, - `refs/heads/**/*${q}*`, - `refs/heads/**/*${q}*/**`, - `refs/remotes/**/*${q}*`, - `refs/remotes/**/*${q}*/**` - ] - } - // Why: one `*token*` per ref segment because fnmatch `*` can't cross `/`; lets a retyped `/` result match. - const segmented = tokens.map((token) => `*${token}*`).join('/') - const substringQuery = tokens.join('/') - const remoteBranchRootPatterns = - options.remoteNames && options.remoteNames.length > 0 - ? options.remoteNames.flatMap((remote) => [ - `refs/remotes/${remote}/${substringQuery}*`, - `refs/remotes/${remote}/${substringQuery}*/**` - ]) - : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] - const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] - const branchRootPatterns = [ - // Why: branch names often contain slashes (plan/docs); these root patterns also match a local branch beneath any remote. - `refs/heads/${substringQuery}*`, - `refs/heads/${substringQuery}*/**`, - ...remoteBranchRootPatterns - ] - const patterns = - options.patternGroup === 'segmented' - ? segmentedPatterns - : options.patternGroup === 'branchRoot' - ? branchRootPatterns - : [...segmentedPatterns, ...branchRootPatterns] - return [...base, ...patterns] -} - -async function runSearchBaseRefsGit( - path: string, - normalizedQuery: string, - limit: number, - options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } -): Promise<{ stdout: string }> { - return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( - 'for-each-ref-exclude', - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - isForEachRefExcludeUnsupportedError - ) -} - -export function mergeBaseRefSearchResultGroups( - groups: readonly BaseRefSearchResult[][], - limit: number -): BaseRefSearchResult[] { - const seen = new Set() - const merged: BaseRefSearchResult[] = [] - const maxLength = Math.max(0, ...groups.map((group) => group.length)) - for (let index = 0; index < maxLength && merged.length < limit; index += 1) { - for (const group of groups) { - const entry = group[index] - if (!entry || seen.has(entry.refName)) { - continue - } - seen.add(entry.refName) - merged.push(entry) - if (merged.length >= limit) { - break - } - } - } - return merged -} - -export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' - -/** - * Resolve the default push remote for a repo. - * Order: remote configured on the current default branch → origin → the single - * remote when the repo has exactly one → error. - */ +/** Resolve the configured push remote without assuming a provider. */ export async function getDefaultRemote( path: string, options: LocalGitExecOptions = {} ): Promise { const defaultRef = await getDefaultBaseRefAsync(path, options) - // Why: getDefaultBaseRefAsync returns null when no default branch exists; guard so .includes() can't crash. const defaultBranch = defaultRef ? defaultRef.includes('/') ? defaultRef.split('/').slice(1).join('/') @@ -859,179 +167,7 @@ export async function getDefaultRemote( } } -export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { - return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) -} - -export async function searchBaseRefDetails( - path: string, - query: string, - limit = 25 -): Promise { - if (!Number.isInteger(limit) || limit <= 0) { - return [] - } - const normalizedQuery = normalizeRefSearchQuery(query) - - try { - // Why: argv lives in buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const remotes = await listRemoteNames(path) - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length > 1) { - // Why: slash queries need both display-format and local-branch matches; merge before the limit so neither starves. - const results = await Promise.all([ - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'segmented' - }), - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'branchRoot' - }) - ]) - return mergeBaseRefSearchResultGroups( - results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), - limit - ) - } - - const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes - }) - return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) - } catch (err) { - // Why: log so a missing result set is debuggable; callers still treat [] as "no matches". - console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) - return [] - } -} - -async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): Promise { - try { - const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) - return stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - } catch { - return [] - } -} - -export function parseAndFilterSearchRefDetails( - stdout: string, - limit: number, - remotes: string[] = [] -): BaseRefSearchResult[] { - const seen = new Set() - const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) - return ( - stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const nul = line.indexOf('\0') - if (nul === -1) { - // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. - return null - } - return { full: line.slice(0, nul), short: line.slice(nul + 1) } - }) - .filter((entry): entry is { full: string; short: string } => entry !== null) - // Why: drop `/HEAD` pseudo-refs; `.+` (not `[^/]+`) since git allows slashes in remote names. - .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) - .filter(({ short }) => { - if (seen.has(short)) { - return false - } - seen.add(short) - return true - }) - .map(({ full, short }) => ({ - refName: short, - localBranchName: resolveLocalBranchName(full, short, sortedRemotes) - })) - // Why: Math.max(0, limit) so pathological limit <= 0 yields zero results, not one. - .slice(0, Math.max(0, limit)) - ) -} - -function resolveLocalBranchName(fullRef: string, shortRef: string, remotes: string[]): string { - const remoteRefPrefix = 'refs/remotes/' - if (!fullRef.startsWith(remoteRefPrefix)) { - return shortRef - } - const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) - const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) - if (remote) { - return remoteAndBranch.slice(remote.length + 1) - } - return remoteAndBranch.split('/').slice(1).join('/') || shortRef -} - -export function normalizeRefSearchQuery(query: string): string { - return query.trim().replace(/[*?[\]\\]/g, '') -} - -async function hasGitRefAsync( - path: string, - ref: string, - options: LocalGitExecOptions = {} -): Promise { - try { - await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) - return true - } catch { - return false - } -} - -export type BranchConflictKind = 'local' | 'remote' - -export async function getBranchConflictKind( - path: string, - branchName: string, - allowedBaseRef?: string, - options: LocalGitExecOptions = {} -): Promise { - if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { - return 'local' - } - - try { - const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) - const { stdout } = await gitExecFileAsync( - ['for-each-ref', '--format=%(refname)', 'refs/remotes'], - gitExecOptions(path, options) - ) - const hasRemoteConflict = stdout.split('\n').some((ref) => { - const trimmed = ref.trim() - if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { - return false - } - const shortRef = trimmed.replace(/^refs\/remotes\//, '') - // Why: git allows slashes in remote names; use the configured list so foo/bar/feature resolves to branch "feature". - return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName - }) - - return hasRemoteConflict ? 'remote' : null - } catch { - return null - } -} - -function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { - if (!allowedBaseRef) { - return false - } - const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') - ? allowedBaseRef - : `refs/remotes/${allowedBaseRef}` - return refName === normalizedAllowedRef -} - -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a file+line; null when the remote isn't a recognized host. */ +/** Build a hosted file URL when the origin belongs to a supported provider. */ export function getRemoteFileUrl( repoPath: string, relativePath: string, @@ -1041,21 +177,20 @@ export function getRemoteFileUrl( if (!remoteUrl) { return null } - const defaultBaseRef = getDefaultBaseRef(repoPath) if (!defaultBaseRef) { return null } - const defaultBranch = defaultBaseRef.replace(/^origin\//, '') - - return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) + return buildHostedRemoteFileUrl( + remoteUrl, + relativePath, + defaultBaseRef.replace(/^origin\//, ''), + line + ) } -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a commit; null when origin isn't a recognized host. */ +/** Build a hosted commit URL when the origin belongs to a supported provider. */ export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { const remoteUrl = getRemoteUrl(repoPath) - if (!remoteUrl) { - return null - } - return buildHostedRemoteCommitUrl(remoteUrl, sha) + return remoteUrl ? buildHostedRemoteCommitUrl(remoteUrl, sha) : null } diff --git a/src/main/github/issue-timeline.ts b/src/main/github/issue-timeline.ts new file mode 100644 index 00000000000..3cc1f36eaac --- /dev/null +++ b/src/main/github/issue-timeline.ts @@ -0,0 +1,189 @@ +import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget +} from '../../shared/github/comment-types' +import { ghExecFileAsync } from './gh-utils' +import type { GitHubApiRepository, GitHubRepoExecOptions } from './github-api-repository' +import { githubHostExecOptions } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_ISSUE_TIMELINE_ITEMS = 300 +const GITHUB_REST_PAGE_SIZE = 100 + +type RestTimelineUser = { + login?: string | null + avatar_url?: string | null +} + +type RestTimelineIssue = { + number?: number | null + title?: string | null + html_url?: string | null + repository?: { + name?: string | null + owner?: { login?: string | null } | null + } | null + pull_request?: unknown +} + +type RestTimelineEvent = { + id?: number | string | null + node_id?: string | null + event?: string | null + actor?: RestTimelineUser | null + user?: RestTimelineUser | null + assignee?: RestTimelineUser | null + created_at?: string | null + source?: { issue?: RestTimelineIssue | null } | null + closer?: RestTimelineIssue | null + state_reason?: string | null + project_card?: { + column_name?: string | null + previous_column_name?: string | null + project_url?: string | null + } | null + project?: { name?: string | null } | null + project_column_name?: string | null + previous_column_name?: string | null +} + +function isSupportedTimelineEvent( + eventName: string | null | undefined +): eventName is GitHubIssueTimelineItem['event'] { + return ( + eventName === 'assigned' || + eventName === 'unassigned' || + eventName === 'mentioned' || + eventName === 'cross-referenced' || + eventName === 'closed' || + eventName === 'reopened' || + eventName === 'moved_columns_in_project' + ) +} + +function mapTimelineTarget( + issue: RestTimelineIssue | null | undefined +): GitHubIssueTimelineTarget | undefined { + if (!issue || typeof issue.number !== 'number' || !issue.html_url) { + return undefined + } + const owner = issue.repository?.owner?.login + const repo = issue.repository?.name + return { + type: issue.pull_request ? 'pr' : 'issue', + number: issue.number, + title: issue.title ?? '', + url: issue.html_url, + repository: owner && repo ? `${owner}/${repo}` : undefined + } +} + +function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { + const actor = event.actor ?? event.user + return { + login: actor?.login ?? 'ghost', + avatarUrl: actor?.avatar_url ?? '' + } +} + +function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { + const eventName = event.event + if (!isSupportedTimelineEvent(eventName) || !event.created_at) { + return null + } + const actor = getTimelineActor(event) + const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) + const base = { + id, + event: eventName, + actor: actor.login, + actorAvatarUrl: actor.avatarUrl, + createdAt: event.created_at + } + if (eventName === 'assigned' || eventName === 'unassigned') { + return { ...base, assignee: event.assignee?.login ?? undefined } + } + if (eventName === 'mentioned' || eventName === 'cross-referenced') { + return { ...base, source: mapTimelineTarget(event.source?.issue) } + } + if (eventName === 'closed') { + return { + ...base, + stateReason: event.state_reason ?? null, + closer: mapTimelineTarget(event.closer ?? event.source?.issue) + } + } + if (eventName === 'moved_columns_in_project') { + return { + ...base, + previousColumnName: + event.previous_column_name ?? event.project_card?.previous_column_name ?? null, + columnName: event.project_column_name ?? event.project_card?.column_name ?? null, + projectName: event.project?.name ?? null + } + } + return base +} + +function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { + const events: RestTimelineEvent[] = [] + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + try { + const parsed = JSON.parse(trimmed) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + events.push(parsed) + } + } catch { + // Timeline activity is auxiliary to issue details. + } + } + return events +} + +export async function getIssueTimelineItems( + repository: GitHubApiRepository, + issueNumber: number, + ghOptions: GitHubRepoExecOptions +): Promise { + try { + const items: GitHubIssueTimelineItem[] = [] + for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return items + } + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, + '--jq', + '.[] | @json' + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + const pageEvents = parseRestTimelineEventLines(stdout) + for (const event of pageEvents) { + const item = mapRestTimelineEvent(event) + if (!item) { + continue + } + items.push(item) + if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { + break + } + } + if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { + break + } + } + return items + } catch { + return [] + } +} diff --git a/src/main/github/issue-work-item-details.ts b/src/main/github/issue-work-item-details.ts new file mode 100644 index 00000000000..d58491abd87 --- /dev/null +++ b/src/main/github/issue-work-item-details.ts @@ -0,0 +1,260 @@ +import type { GitHubIssueTimelineItem, PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getIssueTimelineItems } from './issue-timeline' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + body + assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + comments(first: 100) { + nodes { + databaseId + body + createdAt + url + author { + login + avatarUrl(size: 48) + ... on Bot { __typename } + } + } + } + } + } +}` + +type GraphQLIssueDetailsResponse = { + data?: { + repository?: { + issue?: { + body?: string | null + assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } + participants?: { nodes?: GitHubAssignableUser[] } + comments?: { + nodes?: { + databaseId?: number | null + body?: string | null + createdAt?: string | null + url?: string | null + author?: { + login?: string | null + avatarUrl?: string | null + __typename?: string + } | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] +} + +export type CollapsedIssueDetails = { + body: string + comments: PRComment[] + assignees: string[] + assigneeUsers: GitHubAssignableUser[] + participants: GitHubAssignableUser[] + timelineItems: GitHubIssueTimelineItem[] +} + +export async function getIssueDetailsViaGraphQL( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (!repository) { + return null + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${ISSUE_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${issueNumber}` + ], + ghOptions + ) + const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const issue = parsed.data?.repository?.issue + if (!issue) { + return null + } + const comments: PRComment[] = (issue.comments?.nodes ?? []) + .filter((comment) => typeof comment.databaseId === 'number') + .map((comment) => ({ + id: comment.databaseId as number, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: comment.author?.avatarUrl ?? '', + body: comment.body ?? '', + createdAt: comment.createdAt ?? '', + url: comment.url ?? '', + isBot: comment.author?.__typename === 'Bot' + })) + const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) + .filter((assignee): assignee is { login: string; avatarUrl?: string; name?: string | null } => + Boolean(assignee.login) + ) + .map((assignee) => ({ + login: assignee.login, + name: assignee.name ?? null, + avatarUrl: assignee.avatarUrl ?? '' + })) + const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) + .filter((user) => Boolean(user.login)) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + return { + body: issue.body ?? '', + comments, + assignees: assigneeUsers.map((assignee) => assignee.login), + assigneeUsers, + participants, + timelineItems: await getIssueTimelineItems(repository, issueNumber, ghOptions) + } + } catch { + return null + } +} + +export async function getIssueBodyAndComments( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ + body: string + comments: PRComment[] + assignees: string[] + timelineItems: GitHubIssueTimelineItem[] +}> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + if (repository) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + noteRepositoryRateLimitSpend(repository, 'core', 2, ghOptions) + const [issueResult, commentsResult, timelineItems] = await Promise.all([ + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}` + ], + ghOptions + ), + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/comments?per_page=100` + ], + ghOptions + ), + getIssueTimelineItems(repository, issueNumber, ghOptions) + ]) + const issue = JSON.parse(issueResult.stdout) as { + body?: string | null + assignees?: { login: string }[] + } + type RESTComment = { + id: number + user: { login: string; avatar_url: string; type?: string } | null + body: string + created_at: string + html_url: string + } + const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( + (comment): PRComment => ({ + id: comment.id, + author: comment.user?.login ?? 'ghost', + authorAvatarUrl: comment.user?.avatar_url ?? '', + body: comment.body ?? '', + createdAt: comment.created_at, + url: comment.html_url, + isBot: comment.user?.type === 'Bot' + }) + ) + return { + body: issue.body ?? '', + comments, + assignees: (issue.assignees ?? []).map((assignee) => assignee.login), + timelineItems + } + } + if (connectionId) { + // A connection-backed lookup must not use ambient local GH_REPO/GH_HOST. + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + const { stdout } = await ghExecFileAsync( + ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string + comments?: { + author: { login: string } + body: string + createdAt: string + url: string + }[] + assignees?: { login: string }[] + } + const comments = (data.comments ?? []).map( + (comment, index): PRComment => ({ + id: index, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: '', + body: comment.body ?? '', + createdAt: comment.createdAt, + url: comment.url ?? '' + }) + ) + return { + body: data.body ?? '', + comments, + assignees: (data.assignees ?? []).map((assignee) => assignee.login), + timelineItems: [] + } + } catch { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } +} diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts new file mode 100644 index 00000000000..e2868edf051 --- /dev/null +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -0,0 +1,183 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubPRBranchLookupOptions } from './client' +import { NO_REVIEW_REFRESH_INTERVAL_MS } from '../source-control/hosted-review-refresh-pacing' + +export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 +export const POST_PUSH_DELAY_MS = 2_500 + +type PRBranchLookupCandidate = Pick< + GitHubPRRefreshCandidate, + 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' +> + +function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { + return ( + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + ) +} + +export function hostedReviewOptionArgs( + candidate: PRBranchLookupCandidate +): [] | [GitHubPRBranchLookupOptions] { + const options: GitHubPRBranchLookupOptions = {} + if (candidate.localGitOptions?.wslDistro) { + options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } + } + if (shouldAcceptMergedFallbackPR(candidate)) { + options.acceptMergedFallbackPR = true + } + if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { + options.currentHeadOid = candidate.currentHeadOid.trim() + } + return Object.keys(options).length > 0 ? [options] : [] +} + +export function refreshKey(candidate: GitHubPRRefreshCandidate): string { + const connectionScope = candidate.connectionId ?? 'local' + const runtimeScope = candidate.connectionId + ? 'remote' + : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` + if (typeof candidate.linkedPRNumber === 'number') { + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` + } + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` +} + +export function validateCandidate( + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshSkippedReason | null { + if (candidate.repoKind !== 'git') { + return 'not-git' + } + if (candidate.isBare) { + return 'bare' + } + if (candidate.isArchived) { + return 'archived' + } + if (candidate.connectionId && candidate.connectionState === 'disconnected') { + return 'disconnected' + } + if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { + return 'fresh' + } + return null +} + +export function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' || reason === 'active' || reason === 'post-push' +} + +export function isBackground(reason: GitHubPRRefreshReason): boolean { + return reason !== 'manual' +} + +export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { + return reason === 'visible' || reason === 'swr' +} + +export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { + if (isBudgetedBackground(reason)) { + return false + } + const delay = dueAt - Date.now() + return delay > 0 && delay <= 5_000 +} + +export function shouldSkipFresh( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason +): boolean { + if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { + return false + } + return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) +} + +export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { + return candidate.cachedFetchedAt == null + ? null + : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) +} + +export function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { + return { + cacheKey: candidate.cacheKey, + repoId: candidate.repoId, + repoPath: candidate.repoPath, + branch: candidate.branch, + worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null + } +} + +export function visibleCandidateAfterOutcome( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +): GitHubPRRefreshCandidate { + if (outcome.kind === 'upstream-error') { + return candidate + } + return { + ...candidate, + cachedFetchedAt: outcome.fetchedAt, + cachedHasPR: outcome.kind === 'found', + cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, + cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, + cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, + cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null + } +} + +function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { + if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { + return 30 * 60_000 + } + if (candidate.cachedHasPR === false) { + return NO_REVIEW_REFRESH_INTERVAL_MS + } + if ( + candidate.cachedHasPR === true && + candidate.cachedPRState === 'open' && + candidate.cachedMergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) + ) { + return 10_000 + } + if (candidate.cachedChecksStatus === 'success') { + return 10 * 60_000 + } + if (candidate.cachedChecksStatus === 'failure') { + return 3 * 60_000 + } + if (candidate.cachedChecksStatus === 'pending') { + return 90_000 + } + return 60_000 +} + +function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { + return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' +} + +export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { + return ( + outcome.kind === 'found' && + outcome.pr.state === 'open' && + outcome.pr.mergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) + ) +} diff --git a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts index 8d81faeb0f7..29b5309d26b 100644 --- a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts +++ b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts @@ -164,4 +164,58 @@ describe('pr-refresh-coordinator', () => { 'feature/0' ]) }) + + it.each([ + { primaryResetAt: 4, retryDisabledUntil: 5_000, expectedGateUntil: 5_000 }, + { primaryResetAt: 6, retryDisabledUntil: 5_000, expectedGateUntil: 6_000 } + ])( + 'uses the later manual gate when the primary reset is $primaryResetAt seconds', + async ({ primaryResetAt, retryDisabledUntil, expectedGateUntil }) => { + const { refreshPRNow, reportVisiblePRRefreshCandidates } = + await import('./pr-refresh-coordinator') + const candidate = makeCandidate() + getPRForBranchOutcomeMock.mockResolvedValueOnce({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'limited', + fetchedAt: Date.now(), + retryDisabledUntil + }) + + reportVisiblePRRefreshCandidates([candidate], 1, 1) + await refreshPRNow(candidate) + getPRForBranchOutcomeMock.mockClear() + repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => + bucket === 'core' + ? { blocked: true, remaining: 0, limit: 5_000, resetAt: primaryResetAt } + : { blocked: false } + ) + + const outcome = await refreshPRNow(candidate) + + expect(outcome).toEqual({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', + fetchedAt: 1_000, + nextAutoRetryAt: expectedGateUntil, + retryDisabledUntil: expectedGateUntil + }) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + expect(sendMock.mock.calls.map(([, event]) => event).at(-1)).toEqual( + expect.objectContaining({ + reason: 'manual', + status: 'paused', + pausedUntil: expectedGateUntil, + skippedReason: 'rate-limit' + }) + ) + + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: expectedGateUntil }) + await vi.advanceTimersByTimeAsync(expectedGateUntil - Date.now() - 1) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts index 9d8d0745645..45a026dabbd 100644 --- a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts +++ b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts @@ -27,6 +27,43 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) + it('preserves the coordinator public module API', async () => { + const coordinator = await import('./pr-refresh-coordinator') + + expect(Object.keys(coordinator).sort()).toEqual([ + '_getPRRefreshAliasCountForTests', + '_getPRRefreshErrorBackoffCountForTests', + '_getPRRefreshQueueSizeForTests', + '_getVisiblePRRefreshWindowCountForTests', + 'clearVisiblePRRefreshWindow', + 'enqueuePRRefresh', + 'pruneWorktreePRRefreshAliases', + 'refreshPRNow', + 'reportVisiblePRRefreshCandidates', + 'setPRRefreshOutcomeObserver' + ]) + }) + + it('ignores a stale visibility generation before it can replace queued work', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: Date.now() }) + + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/current', cacheKey: '/repo::feature/current' })], + 2, + 1 + ) + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/stale', cacheKey: '/repo::feature/stale' })], + 1, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + expect(getPRForBranchOutcomeMock.mock.calls[0]?.[1]).toBe('feature/current') + }) + it('sends each refresh event once without broadcasting to 100 browser guests', async () => { const guestSends = Array.from({ length: 100 }, () => vi.fn()) getAllWebContentsMock.mockReturnValue( @@ -109,6 +146,46 @@ describe('pr-refresh-coordinator', () => { expect(outcome?.sequence).toBe(inFlight?.sequence) }) + it('keeps each manual outcome on its request sequence when completions race', async () => { + const { refreshPRNow } = await import('./pr-refresh-coordinator') + let resolveFirst!: (outcome: ReturnType) => void + let resolveSecond!: (outcome: ReturnType) => void + const first = new Promise>((resolve) => { + resolveFirst = resolve + }) + const second = new Promise>((resolve) => { + resolveSecond = resolve + }) + getPRForBranchOutcomeMock + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await first, + fetchedAt: Date.now() + })) + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await second, + fetchedAt: Date.now() + })) + + const firstRefresh = refreshPRNow(makeCandidate()) + const secondRefresh = refreshPRNow(makeCandidate()) + await vi.advanceTimersByTimeAsync(0) + resolveSecond(makePR({ number: 22 })) + await secondRefresh + resolveFirst(makePR({ number: 11 })) + await firstRefresh + + const events = sendMock.mock.calls.map(([, event]) => event) + const inFlight = events.filter((event) => event.status === 'in-flight') + const outcomes = events.filter((event) => event.outcome) + const firstOutcome = outcomes.find((event) => event.outcome.pr?.number === 11) + const secondOutcome = outcomes.find((event) => event.outcome.pr?.number === 22) + expect(firstOutcome?.sequence).toBe(inFlight[0]?.sequence) + expect(secondOutcome?.sequence).toBe(inFlight[1]?.sequence) + expect(firstOutcome?.sequence).toBeLessThan(secondOutcome?.sequence) + }) + it('accepts merged fallback PRs for visible fallback refreshes', async () => { const { refreshPRNow } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 2ff9d0e410b..22846cbe17c 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -1,817 +1,61 @@ -/* eslint-disable max-lines -- Why: queueing, pacing, and broadcast rules stay together so freshness and rate-limit invariants are reviewable in one place. */ -import { webContents } from 'electron' import type { - GitHubPRRefreshAlias, GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, GitHubPRRefreshReason, - GitHubPRRefreshSkippedReason, PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' -import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getOriginGitHubApiRepository } from './github-api-repository' -import { ghRepoExecOptions, githubRepoContext } from './gh-utils' -import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' +import { getPRForBranchOutcome } from './client' import { - lookupBackoffDelayMs, - NO_REVIEW_REFRESH_INTERVAL_MS -} from '../source-control/hosted-review-refresh-pacing' -import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { sendToTrustedUIRenderer } from '../ipc/ui' + aliasFromCandidate, + hostedReviewOptionArgs, + MANUAL_MERGEABILITY_PENDING_REFRESH_MS, + refreshKey, + shouldBroadcastQueued, + validateCandidate +} from './pr-refresh-candidate-policy' +import { + PRRefreshEventPublisher, + type PRRefreshOutcomeObserver +} from './pr-refresh-event-publisher' +import { PRRefreshPacing } from './pr-refresh-pacing' +import { PRRefreshQueue } from './pr-refresh-queue' +import { PRRefreshQueueDrainer } from './pr-refresh-queue-drainer' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import { PRRefreshRetryState } from './pr-refresh-retry-state' +import { PRRefreshVisibility } from './pr-refresh-visibility' -type QueueEntry = { - key: string - candidate: GitHubPRRefreshCandidate - aliases: Map - reason: GitHubPRRefreshReason - priority: number - dueAt: number - queuedAt: number - bypassBackgroundBudget?: boolean - activeDelayNotified?: boolean - windowId?: number -} - -type PRRefreshOutcomeObserver = ( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -) => void - -type PRBranchLookupCandidate = Pick< - GitHubPRRefreshCandidate, - 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' -> - -function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { - return ( - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - ) -} - -function hostedReviewOptionArgs( - candidate: PRBranchLookupCandidate -): [] | [GitHubPRBranchLookupOptions] { - const options: GitHubPRBranchLookupOptions = {} - if (candidate.localGitOptions?.wslDistro) { - options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } - } - if (shouldAcceptMergedFallbackPR(candidate)) { - options.acceptMergedFallbackPR = true - } - if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { - options.currentHeadOid = candidate.currentHeadOid.trim() - } - return Object.keys(options).length > 0 ? [options] : [] -} - -const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 -const MERGEABILITY_PENDING_REFRESH_MS = 10_000 -const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 -const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 -const MIN_BACKGROUND_SPACING_MS = 10_000 -const BACKGROUND_BUDGET_MAX = 20 -const POST_PUSH_DELAY_MS = 2_500 -const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 -const ACTIVE_BURST_WINDOW_MS = 30_000 -const ACTIVE_BURST_MAX = 3 - -let sequence = 0 -let queueOrder = 0 -let draining = false -let drainTimer: ReturnType | null = null -const queue = new Map() -const backgroundStarts: number[] = [] -const activeStartsByScope = new Map() -const errorBackoff = new Map() -// Per-key manual-retry cooldown (secondary rate-limit Retry-After); refreshPRNow refuses manual retry until this time so a stale renderer can't bypass it. -const manualRetryGates = new Map() -let lastBackgroundStartAt = 0 - -/** - * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. - * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other settled outcome clears it. - */ -function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { - manualRetryGates.set(key, outcome.retryDisabledUntil) - } else { - manualRetryGates.delete(key) - } -} - -/** Reset all per-key retry state (backoff + manual cooldown) when a key resets. */ -function resetKeyRetryState(key: string): void { - errorBackoff.delete(key) - manualRetryGates.delete(key) -} -const visibleByWindow = new Map }>() -let outcomeObserver: PRRefreshOutcomeObserver | null = null -const diagnosticsCounters = { - enqueued: 0, - coalesced: 0, - skipped: 0, - backgroundPauses: 0 -} +const retry = new PRRefreshRetryState() +const queue = new PRRefreshQueue((key) => retry.reset(key)) +const pacing = new PRRefreshPacing() +const visibility = new PRRefreshVisibility() +const events = new PRRefreshEventPublisher() +const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events) export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { - outcomeObserver = observer + events.setOutcomeObserver(observer) } function removeInvisibleVisibleRefreshes(): void { - for (const [key, entry] of queue) { - if (entry.reason === 'visible' && !isVisibleKey(key)) { - queue.delete(key) - resetKeyRetryState(key) - broadcast({ - aliases: Array.from(entry.aliases.values()), - reason: 'visible', - status: 'skipped', - skippedReason: 'fresh' - }) - } - } -} - -function recordPRRefreshQueueDiagnostic( - event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', - reason: GitHubPRRefreshReason, - skippedReason?: GitHubPRRefreshSkippedReason -): void { - recordCoalescedCrashBreadcrumb({ - name: 'pr_refresh_queue', - coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, - minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, - data: { - event, - reason, - ...(skippedReason ? { skippedReason } : {}), - enqueued: diagnosticsCounters.enqueued, - coalesced: diagnosticsCounters.coalesced, - skipped: diagnosticsCounters.skipped, - backgroundPauses: diagnosticsCounters.backgroundPauses - } - }) -} - -function clearActiveBurstWindow(windowId: number): void { - const windowPrefix = `${windowId}::` - for (const scope of Array.from(activeStartsByScope.keys())) { - if (scope.startsWith(windowPrefix)) { - activeStartsByScope.delete(scope) - } + const removed = queue.removeInvisibleVisibleEntries((key) => visibility.has(key)) + for (const entry of removed) { + events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) } } export function clearVisiblePRRefreshWindow(windowId: number): void { - const hadVisibleRefreshes = visibleByWindow.delete(windowId) - clearActiveBurstWindow(windowId) + const hadVisibleRefreshes = visibility.clearWindow(windowId) + pacing.clearActiveBurstWindow(windowId) if (hadVisibleRefreshes) { - // Why: visible follow-ups are owned by the reporting renderer; if its WebContents is destroyed, no later visibility report arrives. removeInvisibleVisibleRefreshes() } } -/** - * Drop a removed worktree's aliases from every queue entry. - * - * Why: aliases are otherwise only pruned when a candidate is re-enqueued as invalid, so churning worktrees grow these maps unbounded (OOM creep). - */ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { - for (const [key, entry] of queue) { - let removed = false - for (const [cacheKey, alias] of entry.aliases) { - if (alias.worktreeId === worktreeId) { - entry.aliases.delete(cacheKey) - removed = true - } - } - if (!removed) { - continue - } - // No aliases left means no worktree still cares about this refresh. - if (entry.aliases.size === 0) { - queue.delete(key) - resetKeyRetryState(key) - continue - } - // Keep the entry alive but replace its representative so it isn't a dangling reference to the removed worktree. - if (entry.candidate.worktreeId === worktreeId) { - const replacementAlias = entry.aliases.values().next().value - if (replacementAlias) { - entry.candidate = { - ...entry.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the removed worktree's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null - } - } - } - } -} - -function nextSequence(): number { - sequence += 1 - return sequence -} - -function nextQueueOrder(): number { - queueOrder += 1 - return queueOrder -} - -function broadcast(event: Omit, sequenceOverride?: number): void { - const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent - sendToTrustedUIRenderer('gh:prRefreshEvent', payload) -} - -function refreshKey(candidate: GitHubPRRefreshCandidate): string { - const connectionScope = candidate.connectionId ?? 'local' - const runtimeScope = candidate.connectionId - ? 'remote' - : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` - if (typeof candidate.linkedPRNumber === 'number') { - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` - } - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` -} - -function isVisibleKey(key: string): boolean { - const liveWindowIds = new Set( - webContents - .getAllWebContents() - .filter((wc) => !wc.isDestroyed()) - .map((wc) => wc.id) - ) - for (const windowId of Array.from(visibleByWindow.keys())) { - if (!liveWindowIds.has(windowId)) { - visibleByWindow.delete(windowId) - } - } - for (const visible of visibleByWindow.values()) { - if (visible.keys.has(key)) { - return true - } - } - return false -} - -function isManual(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' -} - -function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' || reason === 'active' || reason === 'post-push' -} - -function isBackground(reason: GitHubPRRefreshReason): boolean { - return reason !== 'manual' -} - -function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { - return reason === 'visible' || reason === 'swr' -} - -function isBudgetedQueueEntry(entry: QueueEntry): boolean { - return isBudgetedBackground(entry.reason) && entry.bypassBackgroundBudget !== true -} - -function validateCandidate( - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshSkippedReason | null { - if (candidate.repoKind !== 'git') { - return 'not-git' - } - if (candidate.isBare) { - return 'bare' - } - if (candidate.isArchived) { - return 'archived' - } - if (candidate.connectionId && candidate.connectionState === 'disconnected') { - return 'disconnected' - } - if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { - return 'fresh' - } - return null -} - -function shouldSkipFresh( - candidate: GitHubPRRefreshCandidate, - reason: GitHubPRRefreshReason -): boolean { - if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { - return false - } - return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) -} - -function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { - if (isBudgetedBackground(reason)) { - return false - } - const delay = dueAt - Date.now() - if (delay <= 0) { - return false - } - return delay <= 5_000 -} - -function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { - return candidate.cachedFetchedAt == null - ? null - : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) -} - -function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { - return { - cacheKey: candidate.cacheKey, - repoId: candidate.repoId, - repoPath: candidate.repoPath, - branch: candidate.branch, - worktreeId: candidate.worktreeId, - connectionId: candidate.connectionId ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null - } -} - -function visibleCandidateAfterOutcome( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -): GitHubPRRefreshCandidate { - if (outcome.kind === 'upstream-error') { - return candidate - } - return { - ...candidate, - cachedFetchedAt: outcome.fetchedAt, - cachedHasPR: outcome.kind === 'found', - cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, - cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, - cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, - cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null - } -} - -function setVisibleFollowUp(entry: QueueEntry): void { - const existing = queue.get(entry.key) - if (!existing) { - queue.set(entry.key, entry) - return - } - - for (const alias of entry.aliases.values()) { - existing.aliases.set(alias.cacheKey, alias) - } - - // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. - if ( - bypassesFreshnessDelay(existing.reason) || - existing.priority > entry.priority || - existing.dueAt <= entry.dueAt - ) { - return - } - - queue.set(entry.key, { - ...entry, - aliases: existing.aliases - }) -} - -function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefreshAlias): void { - const existing = queue.get(key) - if (!existing) { - return - } - - existing.aliases.delete(alias.cacheKey) - const replacementAlias = existing.aliases.values().next().value - if (!replacementAlias) { - queue.delete(key) - resetKeyRetryState(key) - return - } - - if (existing.candidate.cacheKey === alias.cacheKey) { - existing.candidate = { - ...existing.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the survivor's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null, - isArchived: false, - isBare: false - } - } -} - -/** - * Advances the visible-key error backoff and returns the earliest retry time. - * Why: only visible keys auto-retry, so callers must gate on `isVisibleKey` first. - */ -function nextVisibleErrorRetryAt(key: string): number { - const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = Date.now() + lookupBackoffDelayMs(failures) - errorBackoff.set(key, { failures, retryAt }) - return retryAt -} - -/** - * Stamps the auto-retry time onto an error outcome before broadcast. - * Why: sets only `nextAutoRetryAt`, never `retryDisabledUntil` — that disables *manual* Retry and is reserved for a real rate-limit gate. - */ -function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { - if (outcome.kind !== 'upstream-error') { - return outcome - } - // Why: honor a real Retry-After cooldown the client stamped (`retryDisabledUntil`) so we don't auto-retry into an active secondary rate limit. - const cooldownUntil = outcome.retryDisabledUntil - return { - ...outcome, - nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt - } -} - -function scheduleVisibleFollowUp( - key: string, - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome, - priority: number, - aliases: GitHubPRRefreshAlias[], - windowId?: number, - options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } -): void { - if (!isVisibleKey(key)) { - // Why: manual/active refreshes can remove the queued visible retry after its owner window is gone, orphaning the backoff. - resetKeyRetryState(key) - return - } - if (outcome.kind === 'upstream-error') { - // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. - const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) - setVisibleFollowUp({ - key, - candidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt: retryAt, - queuedAt: nextQueueOrder(), - windowId - }) - // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. - scheduleDrain(retryAt - Date.now()) - return - } - resetKeyRetryState(key) - const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) - const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() - const pendingMergeabilityDueAt = - options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) - ? outcome.fetchedAt + options.pendingMergeabilityDelayMs - : null - const dueAt = - pendingMergeabilityDueAt === null - ? regularDueAt - : Math.min(regularDueAt, pendingMergeabilityDueAt) - // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. - setVisibleFollowUp({ - key, - candidate: followUpCandidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt, - queuedAt: nextQueueOrder(), - // Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible spacing would delay it past the prompt retry window. - bypassBackgroundBudget: pendingMergeabilityDueAt !== null, - windowId - }) - scheduleDrain(Math.max(0, dueAt - Date.now())) -} - -function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { - if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { - return 30 * 60_000 - } - if (candidate.cachedHasPR === false) { - return NO_REVIEW_REFRESH_INTERVAL_MS - } - if ( - candidate.cachedHasPR === true && - candidate.cachedPRState === 'open' && - candidate.cachedMergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) - ) { - // Why: GitHub returns transient UNKNOWN mergeability while computing the test merge; visible merge buttons need a prompt follow-up. - return MERGEABILITY_PENDING_REFRESH_MS - } - if (candidate.cachedChecksStatus === 'success') { - return 10 * 60_000 - } - if (candidate.cachedChecksStatus === 'failure') { - return 3 * 60_000 - } - if (candidate.cachedChecksStatus === 'pending') { - return 90_000 - } - return MIN_BACKGROUND_REFRESH_AGE_MS -} - -function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { - return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' -} - -function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { - return ( - outcome.kind === 'found' && - outcome.pr.state === 'open' && - outcome.pr.mergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) - ) -} - -function backgroundRefreshBuckets(): ('core' | 'graphql')[] { - // Why: branch refreshes prefer REST but can fall back to `gh pr list`, so guard both buckets until the client exposes a per-lookup cost plan. - return ['core', 'graphql'] -} - -function noteBackgroundStart(): void { - const now = Date.now() - lastBackgroundStartAt = now - backgroundStarts.push(now) - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } -} - -function nextBudgetDelay(): number { - const now = Date.now() - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } - const spacingDelay = - lastBackgroundStartAt > 0 - ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - lastBackgroundStartAt)) - : 0 - const windowDelay = - backgroundStarts.length < BACKGROUND_BUDGET_MAX - ? 0 - : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - backgroundStarts[0])) - return Math.max(spacingDelay, windowDelay) -} - -function activeBurstScope(entry: QueueEntry): string { - const runtimeScope = entry.candidate.connectionId - ? `ssh:${entry.candidate.connectionId}` - : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` - return `${entry.windowId ?? 'global'}::${runtimeScope}` -} - -function pruneActiveStarts(scope: string, now: number): number[] { - const activeStarts = activeStartsByScope.get(scope) ?? [] - while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { - activeStarts.shift() - } - if (activeStarts.length === 0) { - activeStartsByScope.delete(scope) - } else { - activeStartsByScope.set(scope, activeStarts) - } - return activeStarts -} - -function nextActiveBurstDelay(entry: QueueEntry): number { - const now = Date.now() - const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) - if (activeStarts.length < ACTIVE_BURST_MAX) { - return 0 - } - return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) -} - -function noteActiveStart(entry: QueueEntry): void { - const now = Date.now() - const scope = activeBurstScope(entry) - const activeStarts = pruneActiveStarts(scope, now) - activeStarts.push(now) - activeStartsByScope.set(scope, activeStarts) -} - -function activeOrder(a: QueueEntry, b: QueueEntry): number { - if (a.reason !== 'active' || b.reason !== 'active') { - return 0 - } - if (activeBurstScope(a) !== activeBurstScope(b)) { - return 0 - } - // Why: refresh the worktree the user lands on before stale transient selections in the same window/runtime scope. - return b.queuedAt - a.queuedAt -} - -function entryDelay(entry: QueueEntry): number { - const activeBurstDelay = entry.reason === 'active' ? nextActiveBurstDelay(entry) : 0 - if (activeBurstDelay > 0) { - return activeBurstDelay - } - return isBudgetedQueueEntry(entry) ? nextBudgetDelay() : 0 -} - -function isActiveBurstDelayed(entry: QueueEntry): boolean { - return entry.reason === 'active' && nextActiveBurstDelay(entry) > 0 -} - -function nextQueuedWakeDelay(excludedKey: string): number | null { - const now = Date.now() - let nextDelay = Number.POSITIVE_INFINITY - for (const entry of queue.values()) { - if (entry.key === excludedKey) { - continue - } - const delay = entry.dueAt > now ? entry.dueAt - now : entryDelay(entry) - nextDelay = Math.min(nextDelay, delay) - } - return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null -} - -function scheduleDrain(delay = 0): void { - if (drainTimer) { - clearTimeout(drainTimer) - } - drainTimer = setTimeout(() => { - drainTimer = null - void drainQueue() - }, delay) -} - -function queuedEntriesByPriority(): QueueEntry[] { - const now = Date.now() - return Array.from(queue.values()).sort((a, b) => { - const aReady = a.dueAt <= now - const bReady = b.dueAt <= now - if (aReady && bReady) { - return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt - } - if (aReady !== bReady) { - return aReady ? -1 : 1 - } - return a.dueAt - b.dueAt || b.priority - a.priority - }) -} - -async function drainQueue(): Promise { - if (draining) { - return - } - draining = true - try { - while (queue.size > 0) { - let next = queuedEntriesByPriority()[0] - const waitMs = next.dueAt - Date.now() - if (waitMs > 0) { - scheduleDrain(waitMs) - return - } - - let delay = entryDelay(next) - if (delay > 0) { - const runnable = queuedEntriesByPriority().find( - (entry) => entry.dueAt <= Date.now() && entryDelay(entry) === 0 - ) - if (runnable && runnable.key !== next.key) { - next = runnable - delay = 0 - } else { - if (isActiveBurstDelayed(next) && !next.activeDelayNotified) { - next.activeDelayNotified = true - broadcast({ - aliases: Array.from(next.aliases.values()), - reason: next.reason, - status: 'queued' - }) - } - if (isBudgetedQueueEntry(next) && nextBudgetDelay() > 0) { - diagnosticsCounters.backgroundPauses += 1 - recordPRRefreshQueueDiagnostic('background-pause', next.reason) - } - scheduleDrain(Math.min(delay, nextQueuedWakeDelay(next.key) ?? delay)) - return - } - } - - queue.delete(next.key) - const aliases = Array.from(next.aliases.values()) - const skippedReason = validateCandidate(next.candidate) - if (skippedReason) { - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) - continue - } - if (next.reason === 'visible' && !isVisibleKey(next.key)) { - resetKeyRetryState(next.key) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) - continue - } - const requestSequence = nextSequence() - const requestStartedAt = Date.now() - broadcast( - { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, - requestSequence - ) - - if (isBackground(next.reason)) { - const executionOptions = ghRepoExecOptions( - githubRepoContext( - next.candidate.repoPath, - next.candidate.connectionId, - next.candidate.localGitOptions - ) - ) - const repository = await getOriginGitHubApiRepository( - next.candidate.repoPath, - next.candidate.connectionId, - executionOptions - ) - // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. - if (spendsSharedGitHubComQuota(repository, executionOptions)) { - // Why: the probe only warms the cache, so failures must fail open (#7553). - await getRateLimit() - } - const buckets = backgroundRefreshBuckets() - const blockedGuard = buckets - .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) - .find((guard) => guard.blocked) - if (blockedGuard?.blocked) { - const retryAt = blockedGuard.resetAt * 1000 - queue.set(next.key, { ...next, dueAt: retryAt }) - broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil: retryAt, - skippedReason: 'rate-limit' - }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) - continue - } - if (isBudgetedQueueEntry(next)) { - noteBackgroundStart() - } - if (next.reason === 'active') { - // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. - noteActiveStart(next) - } - // Why (#11532): the lookup itself now debits the snapshot, so every - // caller is accounted for; debiting here too would double-count. - } - - const outcome = await getPRForBranchOutcome( - next.candidate.repoPath, - next.candidate.branch, - next.candidate.linkedPRNumber ?? null, - next.candidate.connectionId ?? null, - next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(next.candidate) - ) - // Why: compute the retry schedule before broadcasting so the outcome and its follow-up share one timing; only visible keys auto-retry. - let plannedRetryAt: number | undefined - let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { - plannedRetryAt = nextVisibleErrorRetryAt(next.key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) - } - outcomeObserver?.(next.candidate, outcome) - noteManualRetryGate(next.key, broadcastOutcome) - broadcast( - { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, - requestSequence - ) - scheduleVisibleFollowUp( - next.key, - next.candidate, - outcome, - next.priority, - aliases, - next.windowId, - { - plannedRetryAt - } - ) - } - } finally { - draining = false - } + queue.pruneWorktreeAliases(worktreeId) } export function enqueuePRRefresh( @@ -824,66 +68,18 @@ export function enqueuePRRefresh( const key = refreshKey(candidate) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) - broadcast({ - aliases: [alias], - reason, - status: 'skipped', - skippedReason - }) + queue.removeInvalidAlias(key, alias) + events.record('skipped', reason, skippedReason) + events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason }) return } - const existing = queue.get(key) - const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null - const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) - if (existing) { - existing.aliases.set(alias.cacheKey, alias) - diagnosticsCounters.coalesced += 1 - recordPRRefreshQueueDiagnostic('coalesced', reason) - const shouldPromoteExisting = - priority > existing.priority || - isManual(reason) || - (reason === 'active' && existing.reason === 'active') || - (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) - if (shouldPromoteExisting) { - existing.priority = priority - existing.reason = reason - existing.dueAt = Math.min(existing.dueAt, dueAt) - existing.queuedAt = nextQueueOrder() - existing.activeDelayNotified = false - existing.candidate = candidate - existing.windowId = windowId ?? existing.windowId - } else if (existing.candidate.worktreeId === candidate.worktreeId) { - // Why: the representative drives the probe head; refresh probe inputs when its worktree moved head/branch, else a merged linked PR lingers after a switch. - existing.candidate = { - ...existing.candidate, - cacheKey: candidate.cacheKey, - branch: candidate.branch, - currentHeadOid: candidate.currentHeadOid ?? null - } - } - } else { - diagnosticsCounters.enqueued += 1 - recordPRRefreshQueueDiagnostic('enqueued', reason) - queue.set(key, { - key, - candidate, - aliases: new Map([[alias.cacheKey, alias]]), - reason, - priority, - dueAt, - queuedAt: nextQueueOrder(), - windowId - }) + const enqueued = queue.enqueue(candidate, reason, priority, windowId) + events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason) + if (shouldBroadcastQueued(reason, enqueued.dueAt)) { + events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' }) } - // Why: visible/SWR are background maintenance behind the budget queue; only user/action-driven queueing should surface in UI. - if (shouldBroadcastQueued(reason, dueAt)) { - broadcast({ aliases: [alias], reason, status: 'queued' }) - } - scheduleDrain() + drainer.schedule() } export function reportVisiblePRRefreshCandidates( @@ -891,11 +87,9 @@ export function reportVisiblePRRefreshCandidates( generation: number, windowId: number ): void { - const existingVisible = visibleByWindow.get(windowId) - if (existingVisible && generation < existingVisible.generation) { + if (!visibility.report(candidates, generation, windowId)) { return } - visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) removeInvisibleVisibleRefreshes() for (const candidate of candidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) @@ -903,11 +97,11 @@ export function reportVisiblePRRefreshCandidates( } export function _getVisiblePRRefreshWindowCountForTests(): number { - return visibleByWindow.size + return visibility.windowCount } export function _getPRRefreshErrorBackoffCountForTests(): number { - return errorBackoff.size + return retry.errorBackoffCount } export function _getPRRefreshQueueSizeForTests(): number { @@ -915,7 +109,7 @@ export function _getPRRefreshQueueSizeForTests(): number { } export function _getPRRefreshAliasCountForTests(key: string): number { - return queue.get(key)?.aliases.size ?? 0 + return queue.aliasCount(key) } export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { @@ -927,68 +121,54 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise const aliases = Array.from(aliasMap.values()) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) + queue.removeInvalidAlias(key, alias) const outcome: PRRefreshOutcome = { kind: 'upstream-error', errorType: 'unknown', message: `Cannot refresh PR for this worktree: ${skippedReason}`, fetchedAt: Date.now() } - broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) + events.broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) return outcome } - // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. - const manualExecutionOptions = ghRepoExecOptions( - githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) - ) - const manualRepository = await getOriginGitHubApiRepository( - candidate.repoPath, - candidate.connectionId, - manualExecutionOptions - ) - const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) - .find((guard) => guard.blocked) - const secondaryGateUntil = manualRetryGates.get(key) - const gateUntil = Math.max( - manualBlockedGuard?.blocked ? manualBlockedGuard.resetAt * 1000 : 0, - secondaryGateUntil !== undefined ? secondaryGateUntil : 0 - ) + const primaryGateUntil = await prRefreshRateLimitPausedUntil(candidate, false) + const gateUntil = Math.max(primaryGateUntil ?? 0, retry.manualGateUntil(key)) if (gateUntil > Date.now()) { - const retryAt = gateUntil - // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. queue.set(key, { key, candidate, aliases: aliasMap, reason: 'manual', priority: 40, - dueAt: retryAt, - queuedAt: nextQueueOrder() + dueAt: gateUntil, + queuedAt: queue.nextOrder() }) - broadcast({ + events.broadcast({ aliases, reason: 'manual', status: 'paused', - pausedUntil: retryAt, + pausedUntil: gateUntil, skippedReason: 'rate-limit' }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) + drainer.schedule(Math.max(1_000, gateUntil - Date.now())) return { kind: 'upstream-error', errorType: 'rate_limited', message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', fetchedAt: Date.now(), - nextAutoRetryAt: retryAt, - retryDisabledUntil: retryAt + nextAutoRetryAt: gateUntil, + retryDisabledUntil: gateUntil } } queue.delete(key) - const requestSequence = nextSequence() + const requestSequence = events.nextSequence() const requestStartedAt = Date.now() - broadcast({ aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, requestSequence) + events.broadcast( + { aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, + requestSequence + ) const outcome = await getPRForBranchOutcome( candidate.repoPath, candidate.branch, @@ -999,19 +179,18 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise ) let plannedRetryAt: number | undefined let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(key)) { - plannedRetryAt = nextVisibleErrorRetryAt(key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + if (outcome.kind === 'upstream-error' && visibility.has(key)) { + plannedRetryAt = retry.nextVisibleErrorRetryAt(key) + broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt) } - outcomeObserver?.(candidate, outcome) - noteManualRetryGate(key, broadcastOutcome) - broadcast( + events.observe(candidate, outcome) + retry.noteManualGate(key, broadcastOutcome) + events.broadcast( { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, requestSequence ) - scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { + drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { plannedRetryAt, - // Why: GitHub reports UNKNOWN right after `gh pr reopen`; one prompt visible retry replaces the transient label. pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }) return broadcastOutcome diff --git a/src/main/github/pr-refresh-event-publisher.ts b/src/main/github/pr-refresh-event-publisher.ts new file mode 100644 index 00000000000..b028729f1f5 --- /dev/null +++ b/src/main/github/pr-refresh-event-publisher.ts @@ -0,0 +1,69 @@ +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { sendToTrustedUIRenderer } from '../ipc/ui' + +export type PRRefreshOutcomeObserver = ( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +) => void + +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export class PRRefreshEventPublisher { + private sequence = 0 + private observer: PRRefreshOutcomeObserver | null = null + private readonly counters = { enqueued: 0, coalesced: 0, skipped: 0, backgroundPauses: 0 } + + setOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { + this.observer = observer + } + + observe(candidate: GitHubPRRefreshCandidate, outcome: PRRefreshOutcome): void { + this.observer?.(candidate, outcome) + } + + nextSequence(): number { + this.sequence += 1 + return this.sequence + } + + broadcast(event: Omit, sequenceOverride?: number): void { + const payload = { + ...event, + sequence: sequenceOverride ?? this.nextSequence() + } as GitHubPRRefreshEvent + sendToTrustedUIRenderer('gh:prRefreshEvent', payload) + } + + record( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason + ): void { + if (event === 'background-pause') { + this.counters.backgroundPauses += 1 + } else { + this.counters[event] += 1 + } + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: this.counters.enqueued, + coalesced: this.counters.coalesced, + skipped: this.counters.skipped, + backgroundPauses: this.counters.backgroundPauses + } + }) + } +} diff --git a/src/main/github/pr-refresh-pacing.ts b/src/main/github/pr-refresh-pacing.ts new file mode 100644 index 00000000000..7a88bd74a0a --- /dev/null +++ b/src/main/github/pr-refresh-pacing.ts @@ -0,0 +1,114 @@ +import type { PRRefreshQueueEntry } from './pr-refresh-queue' + +const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 +const MIN_BACKGROUND_SPACING_MS = 10_000 +const BACKGROUND_BUDGET_MAX = 20 +const ACTIVE_BURST_WINDOW_MS = 30_000 +const ACTIVE_BURST_MAX = 3 + +export class PRRefreshPacing { + private readonly backgroundStarts: number[] = [] + private readonly activeStartsByScope = new Map() + private lastBackgroundStartAt = 0 + + clearActiveBurstWindow(windowId: number): void { + const windowPrefix = `${windowId}::` + for (const scope of Array.from(this.activeStartsByScope.keys())) { + if (scope.startsWith(windowPrefix)) { + this.activeStartsByScope.delete(scope) + } + } + } + + noteBackgroundStart(): void { + const now = Date.now() + this.lastBackgroundStartAt = now + this.backgroundStarts.push(now) + this.pruneBackgroundStarts(now) + } + + nextBudgetDelay(): number { + const now = Date.now() + this.pruneBackgroundStarts(now) + const spacingDelay = + this.lastBackgroundStartAt > 0 + ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - this.lastBackgroundStartAt)) + : 0 + const windowDelay = + this.backgroundStarts.length < BACKGROUND_BUDGET_MAX + ? 0 + : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - this.backgroundStarts[0])) + return Math.max(spacingDelay, windowDelay) + } + + activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number { + if (a.reason !== 'active' || b.reason !== 'active') { + return 0 + } + if (this.activeBurstScope(a) !== this.activeBurstScope(b)) { + return 0 + } + return b.queuedAt - a.queuedAt + } + + entryDelay(entry: PRRefreshQueueEntry): number { + const activeDelay = entry.reason === 'active' ? this.nextActiveBurstDelay(entry) : 0 + if (activeDelay > 0) { + return activeDelay + } + return entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') + ? this.nextBudgetDelay() + : 0 + } + + isActiveBurstDelayed(entry: PRRefreshQueueEntry): boolean { + return entry.reason === 'active' && this.nextActiveBurstDelay(entry) > 0 + } + + noteActiveStart(entry: PRRefreshQueueEntry): void { + const now = Date.now() + const scope = this.activeBurstScope(entry) + const activeStarts = this.pruneActiveStarts(scope, now) + activeStarts.push(now) + this.activeStartsByScope.set(scope, activeStarts) + } + + private pruneBackgroundStarts(now: number): void { + while ( + this.backgroundStarts.length > 0 && + now - this.backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS + ) { + this.backgroundStarts.shift() + } + } + + private activeBurstScope(entry: PRRefreshQueueEntry): string { + const runtimeScope = entry.candidate.connectionId + ? `ssh:${entry.candidate.connectionId}` + : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` + return `${entry.windowId ?? 'global'}::${runtimeScope}` + } + + private pruneActiveStarts(scope: string, now: number): number[] { + const activeStarts = this.activeStartsByScope.get(scope) ?? [] + while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { + activeStarts.shift() + } + if (activeStarts.length === 0) { + this.activeStartsByScope.delete(scope) + } else { + this.activeStartsByScope.set(scope, activeStarts) + } + return activeStarts + } + + private nextActiveBurstDelay(entry: PRRefreshQueueEntry): number { + const now = Date.now() + const activeStarts = this.pruneActiveStarts(this.activeBurstScope(entry), now) + if (activeStarts.length < ACTIVE_BURST_MAX) { + return 0 + } + return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) + } +} diff --git a/src/main/github/pr-refresh-queue-drainer.ts b/src/main/github/pr-refresh-queue-drainer.ts new file mode 100644 index 00000000000..d1e37492e32 --- /dev/null +++ b/src/main/github/pr-refresh-queue-drainer.ts @@ -0,0 +1,242 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { getPRForBranchOutcome } from './client' +import { + freshRetryAt, + hostedReviewOptionArgs, + isBackground, + isMergeabilityPendingOutcome, + validateCandidate, + visibleCandidateAfterOutcome +} from './pr-refresh-candidate-policy' +import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' +import type { PRRefreshPacing } from './pr-refresh-pacing' +import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import type { PRRefreshRetryState } from './pr-refresh-retry-state' +import type { PRRefreshVisibility } from './pr-refresh-visibility' + +export class PRRefreshQueueDrainer { + private draining = false + private timer: ReturnType | null = null + + constructor( + private readonly queue: PRRefreshQueue, + private readonly pacing: PRRefreshPacing, + private readonly visibility: PRRefreshVisibility, + private readonly retry: PRRefreshRetryState, + private readonly events: PRRefreshEventPublisher + ) {} + + schedule(delay = 0): void { + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = setTimeout(() => { + this.timer = null + void this.drain() + }, delay) + } + + scheduleVisibleFollowUp( + key: string, + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome, + priority: number, + aliases: GitHubPRRefreshAlias[], + windowId?: number, + options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } + ): void { + if (!this.visibility.has(key)) { + this.retry.reset(key) + return + } + if (outcome.kind === 'upstream-error') { + const retryAt = options?.plannedRetryAt ?? this.retry.nextVisibleErrorRetryAt(key) + this.queue.setVisibleFollowUp({ + key, + candidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt: retryAt, + queuedAt: this.queue.nextOrder(), + windowId + }) + this.schedule(retryAt - Date.now()) + return + } + this.retry.reset(key) + const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) + const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() + const pendingDueAt = + options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) + ? outcome.fetchedAt + options.pendingMergeabilityDelayMs + : null + const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt) + this.queue.setVisibleFollowUp({ + key, + candidate: followUpCandidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt, + queuedAt: this.queue.nextOrder(), + bypassBackgroundBudget: pendingDueAt !== null, + windowId + }) + this.schedule(Math.max(0, dueAt - Date.now())) + } + + private ordered(): PRRefreshQueueEntry[] { + return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b)) + } + + private nextQueuedWakeDelay(excludedKey: string): number | null { + const now = Date.now() + let nextDelay = Number.POSITIVE_INFINITY + for (const entry of this.queue.values()) { + if (entry.key === excludedKey) { + continue + } + const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry) + nextDelay = Math.min(nextDelay, delay) + } + return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null + } + + private async drain(): Promise { + if (this.draining) { + return + } + this.draining = true + try { + while (this.queue.size > 0) { + let next = this.ordered()[0] + const waitMs = next.dueAt - Date.now() + if (waitMs > 0) { + this.schedule(waitMs) + return + } + + let delay = this.pacing.entryDelay(next) + if (delay > 0) { + const runnable = this.ordered().find( + (entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0 + ) + if (runnable && runnable.key !== next.key) { + next = runnable + delay = 0 + } else { + this.notePacingDelay(next) + this.schedule(Math.min(delay, this.nextQueuedWakeDelay(next.key) ?? delay)) + return + } + } + + this.queue.delete(next.key) + const aliases = Array.from(next.aliases.values()) + const skippedReason = validateCandidate(next.candidate) + if (skippedReason) { + this.events.record('skipped', next.reason, skippedReason) + this.events.broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) + continue + } + if (next.reason === 'visible' && !this.visibility.has(next.key)) { + this.retry.reset(next.key) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'skipped', + skippedReason: 'fresh' + }) + continue + } + const requestSequence = this.events.nextSequence() + const requestStartedAt = Date.now() + this.events.broadcast( + { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, + requestSequence + ) + + if (isBackground(next.reason)) { + const pausedUntil = await prRefreshRateLimitPausedUntil(next.candidate, true) + if (pausedUntil !== null) { + this.queue.set(next.key, { ...next, dueAt: pausedUntil }) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'paused', + pausedUntil, + skippedReason: 'rate-limit' + }) + this.schedule(Math.max(1_000, pausedUntil - Date.now())) + continue + } + if ( + next.bypassBackgroundBudget !== true && + (next.reason === 'visible' || next.reason === 'swr') + ) { + this.pacing.noteBackgroundStart() + } + if (next.reason === 'active') { + this.pacing.noteActiveStart(next) + } + } + + const outcome = await getPRForBranchOutcome( + next.candidate.repoPath, + next.candidate.branch, + next.candidate.linkedPRNumber ?? null, + next.candidate.connectionId ?? null, + next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs(next.candidate) + ) + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && this.visibility.has(next.key)) { + plannedRetryAt = this.retry.nextVisibleErrorRetryAt(next.key) + broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt) + } + this.events.observe(next.candidate, outcome) + this.retry.noteManualGate(next.key, broadcastOutcome) + this.events.broadcast( + { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) + this.scheduleVisibleFollowUp( + next.key, + next.candidate, + outcome, + next.priority, + aliases, + next.windowId, + { plannedRetryAt } + ) + } + } finally { + this.draining = false + } + } + + private notePacingDelay(entry: PRRefreshQueueEntry): void { + if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) { + entry.activeDelayNotified = true + this.events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: entry.reason, + status: 'queued' + }) + } + if ( + entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') && + this.pacing.nextBudgetDelay() > 0 + ) { + this.events.record('background-pause', entry.reason) + } + } +} diff --git a/src/main/github/pr-refresh-queue.ts b/src/main/github/pr-refresh-queue.ts new file mode 100644 index 00000000000..2cc185535fa --- /dev/null +++ b/src/main/github/pr-refresh-queue.ts @@ -0,0 +1,225 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason +} from '../../shared/github/pull-request-refresh-types' +import { + aliasFromCandidate, + bypassesFreshnessDelay, + freshRetryAt, + POST_PUSH_DELAY_MS, + refreshKey, + shouldSkipFresh +} from './pr-refresh-candidate-policy' + +export type PRRefreshQueueEntry = { + key: string + candidate: GitHubPRRefreshCandidate + aliases: Map + reason: GitHubPRRefreshReason + priority: number + dueAt: number + queuedAt: number + bypassBackgroundBudget?: boolean + activeDelayNotified?: boolean + windowId?: number +} + +export type PRRefreshEnqueue = { + alias: GitHubPRRefreshAlias + key: string + dueAt: number + coalesced: boolean +} + +export class PRRefreshQueue { + private readonly entries = new Map() + private order = 0 + + constructor(private readonly resetRetryState: (key: string) => void) {} + + get size(): number { + return this.entries.size + } + + get(key: string): PRRefreshQueueEntry | undefined { + return this.entries.get(key) + } + + set(key: string, entry: PRRefreshQueueEntry): void { + this.entries.set(key, entry) + } + + delete(key: string): void { + this.entries.delete(key) + } + + values(): IterableIterator { + return this.entries.values() + } + + nextOrder(): number { + this.order += 1 + return this.order + } + + aliasCount(key: string): number { + return this.entries.get(key)?.aliases.size ?? 0 + } + + enqueue( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason, + priority: number, + windowId?: number + ): PRRefreshEnqueue { + const alias = aliasFromCandidate(candidate) + const key = refreshKey(candidate) + const existing = this.entries.get(key) + const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null + const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) + if (!existing) { + this.entries.set(key, { + key, + candidate, + aliases: new Map([[alias.cacheKey, alias]]), + reason, + priority, + dueAt, + queuedAt: this.nextOrder(), + windowId + }) + return { alias, key, dueAt, coalesced: false } + } + + existing.aliases.set(alias.cacheKey, alias) + const shouldPromote = + priority > existing.priority || + reason === 'manual' || + (reason === 'active' && existing.reason === 'active') || + (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) + if (shouldPromote) { + existing.priority = priority + existing.reason = reason + existing.dueAt = Math.min(existing.dueAt, dueAt) + existing.queuedAt = this.nextOrder() + existing.activeDelayNotified = false + existing.candidate = candidate + existing.windowId = windowId ?? existing.windowId + } else if (existing.candidate.worktreeId === candidate.worktreeId) { + existing.candidate = { + ...existing.candidate, + cacheKey: candidate.cacheKey, + branch: candidate.branch, + currentHeadOid: candidate.currentHeadOid ?? null + } + } + return { alias, key, dueAt, coalesced: true } + } + + removeInvalidAlias(key: string, alias: GitHubPRRefreshAlias): void { + const existing = this.entries.get(key) + if (!existing) { + return + } + existing.aliases.delete(alias.cacheKey) + const replacement = existing.aliases.values().next().value + if (!replacement) { + this.entries.delete(key) + this.resetRetryState(key) + return + } + if (existing.candidate.cacheKey === alias.cacheKey) { + existing.candidate = { + ...existing.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null, + isArchived: false, + isBare: false + } + } + } + + pruneWorktreeAliases(worktreeId: string): void { + for (const [key, entry] of this.entries) { + let removed = false + for (const [cacheKey, alias] of entry.aliases) { + if (alias.worktreeId === worktreeId) { + entry.aliases.delete(cacheKey) + removed = true + } + } + if (!removed) { + continue + } + if (entry.aliases.size === 0) { + this.entries.delete(key) + this.resetRetryState(key) + continue + } + if (entry.candidate.worktreeId === worktreeId) { + const replacement = entry.aliases.values().next().value + if (replacement) { + entry.candidate = { + ...entry.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null + } + } + } + } + } + + removeInvisibleVisibleEntries(isVisible: (key: string) => boolean): PRRefreshQueueEntry[] { + const removed: PRRefreshQueueEntry[] = [] + for (const [key, entry] of this.entries) { + if (entry.reason !== 'visible' || isVisible(key)) { + continue + } + this.entries.delete(key) + this.resetRetryState(key) + removed.push(entry) + } + return removed + } + + setVisibleFollowUp(entry: PRRefreshQueueEntry): void { + const existing = this.entries.get(entry.key) + if (!existing) { + this.entries.set(entry.key, entry) + return + } + for (const alias of entry.aliases.values()) { + existing.aliases.set(alias.cacheKey, alias) + } + if ( + bypassesFreshnessDelay(existing.reason) || + existing.priority > entry.priority || + existing.dueAt <= entry.dueAt + ) { + return + } + this.entries.set(entry.key, { ...entry, aliases: existing.aliases }) + } + + ordered( + activeOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number + ): PRRefreshQueueEntry[] { + const now = Date.now() + return Array.from(this.entries.values()).sort((a, b) => { + const aReady = a.dueAt <= now + const bReady = b.dueAt <= now + if (aReady && bReady) { + return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt + } + if (aReady !== bReady) { + return aReady ? -1 : 1 + } + return a.dueAt - b.dueAt || b.priority - a.priority + }) + } +} diff --git a/src/main/github/pr-refresh-rate-limit-gate.ts b/src/main/github/pr-refresh-rate-limit-gate.ts new file mode 100644 index 00000000000..19cee2e215d --- /dev/null +++ b/src/main/github/pr-refresh-rate-limit-gate.ts @@ -0,0 +1,27 @@ +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' + +const BUCKETS = ['core', 'graphql'] as const + +export async function prRefreshRateLimitPausedUntil( + candidate: GitHubPRRefreshCandidate, + warmSharedSnapshot: boolean +): Promise { + const executionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const repository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + executionOptions + ) + if (warmSharedSnapshot && spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const blockedGuard = BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).find((guard) => guard.blocked) + return blockedGuard?.blocked ? blockedGuard.resetAt * 1000 : null +} diff --git a/src/main/github/pr-refresh-retry-state.ts b/src/main/github/pr-refresh-retry-state.ts new file mode 100644 index 00000000000..fa66bf021a4 --- /dev/null +++ b/src/main/github/pr-refresh-retry-state.ts @@ -0,0 +1,46 @@ +import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import { lookupBackoffDelayMs } from '../source-control/hosted-review-refresh-pacing' + +export class PRRefreshRetryState { + private readonly errorBackoff = new Map() + private readonly manualRetryGates = new Map() + + get errorBackoffCount(): number { + return this.errorBackoff.size + } + + reset(key: string): void { + this.errorBackoff.delete(key) + this.manualRetryGates.delete(key) + } + + noteManualGate(key: string, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { + this.manualRetryGates.set(key, outcome.retryDisabledUntil) + } else { + this.manualRetryGates.delete(key) + } + } + + manualGateUntil(key: string): number { + return this.manualRetryGates.get(key) ?? 0 + } + + nextVisibleErrorRetryAt(key: string): number { + const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = Date.now() + lookupBackoffDelayMs(failures) + this.errorBackoff.set(key, { failures, retryAt }) + return retryAt + } + + withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error') { + return outcome + } + const cooldownUntil = outcome.retryDisabledUntil + return { + ...outcome, + nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt + } + } +} diff --git a/src/main/github/pr-refresh-visibility.ts b/src/main/github/pr-refresh-visibility.ts new file mode 100644 index 00000000000..9673252b6fe --- /dev/null +++ b/src/main/github/pr-refresh-visibility.ts @@ -0,0 +1,44 @@ +import { webContents } from 'electron' +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { refreshKey } from './pr-refresh-candidate-policy' + +export class PRRefreshVisibility { + private readonly visibleByWindow = new Map }>() + + get windowCount(): number { + return this.visibleByWindow.size + } + + clearWindow(windowId: number): boolean { + return this.visibleByWindow.delete(windowId) + } + + report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean { + const existing = this.visibleByWindow.get(windowId) + if (existing && generation < existing.generation) { + return false + } + this.visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) + return true + } + + has(key: string): boolean { + const liveWindowIds = new Set( + webContents + .getAllWebContents() + .filter((contents) => !contents.isDestroyed()) + .map((contents) => contents.id) + ) + for (const windowId of Array.from(this.visibleByWindow.keys())) { + if (!liveWindowIds.has(windowId)) { + this.visibleByWindow.delete(windowId) + } + } + for (const visible of this.visibleByWindow.values()) { + if (visible.keys.has(key)) { + return true + } + } + return false + } +} diff --git a/src/main/github/pull-request-file-contents.ts b/src/main/github/pull-request-file-contents.ts new file mode 100644 index 00000000000..fa367e1c68b --- /dev/null +++ b/src/main/github/pull-request-file-contents.ts @@ -0,0 +1,132 @@ +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { + githubHostExecOptions, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from './github-api-repository' +import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 + +function encodeGitHubContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +async function fetchContentAtRef(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + ownerRepo: GitHubApiRepository + path: string + ref: string +}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), + ...githubHostExecOptions(args.ownerRepo), + maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES + } + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { + return { content: '', isBinary: false } + } + try { + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '300s', + '-H', + 'Accept: application/vnd.github.raw', + `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` + ], + ghOptions + ) + const sample = stdout.slice(0, 2048) + if (sample.includes('\u0000')) { + return { content: '', isBinary: true } + } + return { content: stdout, isBinary: false } + } catch (error) { + if (isMaxBufferOverflowError(error)) { + return { content: '', isBinary: false, tooLarge: true } + } + return { content: '', isBinary: false } + } +} + +export async function getPRFileContents(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}): Promise { + const { ownerRepo } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions + ) + if (!ownerRepo) { + return { + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } + + await acquire() + try { + // Added and removed files have no content on one side of the comparison. + const needsOriginal = args.status !== 'added' + const needsModified = args.status !== 'removed' + const originalPath = args.oldPath ?? args.path + const [original, modified] = await Promise.all([ + needsOriginal + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: originalPath, + ref: args.baseSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }), + needsModified + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: args.path, + ref: args.headSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }) + ]) + return { + original: original.content, + modified: modified.content, + originalIsBinary: original.isBinary, + modifiedIsBinary: modified.isBinary, + originalTooLarge: original.tooLarge, + modifiedTooLarge: modified.tooLarge + } + } finally { + release() + } +} diff --git a/src/main/github/pull-request-file-data.ts b/src/main/github/pull-request-file-data.ts new file mode 100644 index 00000000000..62622e4583f --- /dev/null +++ b/src/main/github/pull-request-file-data.ts @@ -0,0 +1,247 @@ +import type { GitHubPRFile, GitHubPRFileViewedState } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_PR_FILES = 300 + +const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + path + viewerViewedState + } + } + } + } +}` + +type RESTPRFile = { + filename: string + previous_filename?: string + status: string + additions: number + deletions: number + changes: number + patch?: string +} + +export type PRFileViewedStatesResult = { + pullRequestId: string + viewedStates: Map +} + +function mapFileStatus(raw: string): GitHubPRFile['status'] { + switch (raw) { + case 'added': + return 'added' + case 'removed': + return 'removed' + case 'modified': + return 'modified' + case 'renamed': + return 'renamed' + case 'copied': + return 'copied' + case 'changed': + return 'changed' + case 'unchanged': + return 'unchanged' + default: + return 'modified' + } +} + +function isBinaryHint(file: RESTPRFile): boolean { + return file.patch === undefined && file.changes > 0 +} + +export async function getPRMetadata( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ body: string; headSha?: string; baseSha?: string }> { + if (!repository) { + return { body: '' } + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '' } + } + try { + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', '--cache', '60s', `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}`], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string | null + head?: { sha?: string } + base?: { sha?: string } + } + return { + body: data.body ?? '', + ...(data.head?.sha ? { headSha: data.head.sha } : {}), + ...(data.base?.sha ? { baseSha: data.base.sha } : {}) + } + } catch { + return { body: '' } + } +} + +// null means unavailable; [] means a successfully fetched empty PR. +export async function getPRFiles( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + const data: RESTPRFile[] = [] + for (let page = 1; data.length < MAX_PR_FILES; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return null + } + const pageSuffix = page === 1 ? '' : `&page=${page}` + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` + ], + ghOptions + ) + const pageData = JSON.parse(stdout) as RESTPRFile[] + data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) + if (pageData.length < 100) { + break + } + } + return data.map((file) => ({ + path: file.filename, + oldPath: file.previous_filename, + status: mapFileStatus(file.status), + additions: file.additions, + deletions: file.deletions, + isBinary: isBinaryHint(file), + reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) + })) + } catch { + return null + } +} + +export async function getPRFileViewedStates( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + const viewedStates = new Map() + let pullRequestId: string | null = null + let after: string | null = null + + try { + for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { + const args = [ + 'api', + 'graphql', + '-f', + `query=${PR_FILE_VIEWED_STATES_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${prNumber}` + ] + if (after) { + args.push('-f', `after=${after}`) + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync(args, ghOptions) + const parsed = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { + id?: string + files?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: { + path?: string | null + viewerViewedState?: GitHubPRFileViewedState | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] + } + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const pullRequest = parsed.data?.repository?.pullRequest + if (!pullRequest?.id) { + return null + } + pullRequestId = pullRequest.id + for (const file of pullRequest.files?.nodes ?? []) { + if (file.path && file.viewerViewedState) { + viewedStates.set(file.path, file.viewerViewedState) + } + } + if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { + break + } + after = pullRequest.files.pageInfo.endCursor + } + } catch { + return null + } + return pullRequestId ? { pullRequestId, viewedStates } : null +} + +export function mergePRFileViewedStates( + files: GitHubPRFile[], + viewedStates: PRFileViewedStatesResult | null +): GitHubPRFile[] { + if (!viewedStates) { + return files + } + return files.map((file) => ({ + ...file, + viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' + })) +} diff --git a/src/main/github/work-item-details-api-parity.test.ts b/src/main/github/work-item-details-api-parity.test.ts new file mode 100644 index 00000000000..187680f64cd --- /dev/null +++ b/src/main/github/work-item-details-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import type { LocalGitExecOptions } from './gh-utils' +import type { GitHubApiRepository } from './github-api-repository' +import * as workItemDetails from './work-item-details' + +type GetWorkItemDetails = ( + repoPath: string, + number: number, + type?: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions?: LocalGitExecOptions, + preference?: IssueSourcePreference +) => Promise + +type GetPRFileContents = (args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}) => Promise + +describe('work-item-details public API parity', () => { + it('retains only the established runtime exports and call signatures', () => { + expect(Object.keys(workItemDetails).sort()).toEqual(['getPRFileContents', 'getWorkItemDetails']) + expectTypeOf(workItemDetails.getWorkItemDetails).toEqualTypeOf() + expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf() + }) +}) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index cac3c33eaf4..d0205332bd5 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -333,6 +333,36 @@ describe('getWorkItemDetails PR file listing', () => { ).toHaveLength(2) }) + it('skips an added file base read and preserves a head max-buffer overflow', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' }) + ) + + const contents = await getPRFileContents({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + prNumber: 7, + path: 'src/large.ts', + status: 'added', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + + expect(contents).toEqual({ + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false, + originalTooLarge: undefined, + modifiedTooLarge: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/team/orca/contents/src/large.ts?ref=head-sha' + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledOnce() + }) + it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { repositoryRateLimitGuardMock.mockReturnValue({ blocked: true, diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 276ee9d33eb..dafc4f514d6 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,1012 +1,41 @@ -/* eslint-disable max-lines -- Why: groups the PR/Issue fetch paths and file-contents resolver so caching/rate-limit strategy lives in one place. */ import type { PRCheckDetail } from '../../shared/github/check-types' -import type { - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - PRComment -} from '../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState -} from '../../shared/github/pull-request-types' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' import type { IssueSourcePreference } from '../../shared/repo-types' -import { - ghExecFileAsync, - acquire, - release, - ghRepoExecOptions, - githubRepoContext, - type LocalGitExecOptions -} from './gh-utils' -import { getWorkItem, getPRChecks, getPRComments } from './client' +import { getPRChecks, getPRComments, getWorkItem } from './client' +import { acquire, release, type LocalGitExecOptions } from './gh-utils' import { getIssueGitHubApiRepository, - githubHostExecOptions, resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository' -import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' -import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' -import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' - -// Why: cap total PR files so a massive PR can't starve the gh semaphore while paging (100/page). -const MAX_PR_FILES = 300 -// Why: bound noisy issue timelines so one huge issue can't monopolize gh/API time. -const MAX_ISSUE_TIMELINE_ITEMS = 300 -const GITHUB_REST_PAGE_SIZE = 100 -// Why: raw-fetch buffer must exceed the renderer's large-diff threshold, else the UI shows an empty diff instead of the fallback. -const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 +import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details' +import { + getPRFiles, + getPRFileViewedStates, + getPRMetadata, + mergePRFileViewedStates +} from './pull-request-file-data' +import { getPRFileContents as loadPRFileContents } from './pull-request-file-contents' +import { + enrichItemDisplayAvatars, + getMentionParticipants, + getWorkItemParticipants +} from './work-item-participants' function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitExecOptions] { return Object.keys(options).length > 0 ? [options] : [] } -function encodeGitHubContentPath(path: string): string { - return path.split('/').map(encodeURIComponent).join('/') -} - -const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id - files(first: 100, after: $after) { - pageInfo { hasNextPage endCursor } - nodes { - path - viewerViewedState - } - } - } - } -}` - -const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) @include(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - issue(number: $number) @skip(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - } -}` - -// Why: one GraphQL round-trip replaces the 3 serial gh subprocesses (REST issue + comments + participants); falls back to the legacy path on failure. -const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - body - assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - comments(first: 100) { - nodes { - databaseId - body - createdAt - url - author { - login - avatarUrl(size: 48) - ... on Bot { __typename } - } - } - } - } - } -}` - -type GraphQLIssueDetailsResponse = { - data?: { - repository?: { - issue?: { - body?: string | null - assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } - participants?: { nodes?: GitHubAssignableUser[] } - comments?: { - nodes?: { - databaseId?: number | null - body?: string | null - createdAt?: string | null - url?: string | null - author?: { - login?: string | null - avatarUrl?: string | null - __typename?: string - } | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] -} - -type RestTimelineUser = { - login?: string | null - avatar_url?: string | null -} - -type RestTimelineIssue = { - number?: number | null - title?: string | null - html_url?: string | null - repository?: { - name?: string | null - owner?: { login?: string | null } | null - } | null - pull_request?: unknown -} - -type RestTimelineEvent = { - id?: number | string | null - node_id?: string | null - event?: string | null - actor?: RestTimelineUser | null - user?: RestTimelineUser | null - assignee?: RestTimelineUser | null - created_at?: string | null - source?: { - issue?: RestTimelineIssue | null - } | null - closer?: RestTimelineIssue | null - state_reason?: string | null - project_card?: { - column_name?: string | null - previous_column_name?: string | null - project_url?: string | null - } | null - project?: { - name?: string | null - } | null - project_column_name?: string | null - previous_column_name?: string | null -} - -function isSupportedTimelineEvent( - eventName: string | null | undefined -): eventName is GitHubIssueTimelineItem['event'] { - return ( - eventName === 'assigned' || - eventName === 'unassigned' || - eventName === 'mentioned' || - eventName === 'cross-referenced' || - eventName === 'closed' || - eventName === 'reopened' || - eventName === 'moved_columns_in_project' - ) -} - -function mapTimelineTarget( - issue: RestTimelineIssue | null | undefined -): GitHubIssueTimelineTarget | undefined { - if (!issue || typeof issue.number !== 'number' || !issue.html_url) { - return undefined - } - const owner = issue.repository?.owner?.login - const repo = issue.repository?.name - return { - type: issue.pull_request ? 'pr' : 'issue', - number: issue.number, - title: issue.title ?? '', - url: issue.html_url, - repository: owner && repo ? `${owner}/${repo}` : undefined - } -} - -function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { - const actor = event.actor ?? event.user - return { - login: actor?.login ?? 'ghost', - avatarUrl: actor?.avatar_url ?? '' - } -} - -function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { - const eventName = event.event - if (!isSupportedTimelineEvent(eventName)) { - return null - } - if (!event.created_at) { - return null - } - const actor = getTimelineActor(event) - const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) - const base = { - id, - event: eventName, - actor: actor.login, - actorAvatarUrl: actor.avatarUrl, - createdAt: event.created_at - } - if (eventName === 'assigned' || eventName === 'unassigned') { - return { - ...base, - assignee: event.assignee?.login ?? undefined - } - } - if (eventName === 'mentioned' || eventName === 'cross-referenced') { - return { - ...base, - source: mapTimelineTarget(event.source?.issue) - } - } - if (eventName === 'closed') { - return { - ...base, - stateReason: event.state_reason ?? null, - closer: mapTimelineTarget(event.closer ?? event.source?.issue) - } - } - if (eventName === 'moved_columns_in_project') { - return { - ...base, - previousColumnName: - event.previous_column_name ?? event.project_card?.previous_column_name ?? null, - columnName: event.project_column_name ?? event.project_card?.column_name ?? null, - projectName: event.project?.name ?? null - } - } - return base -} - -function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { - const events: RestTimelineEvent[] = [] - for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { - continue - } - try { - const parsed = JSON.parse(trimmed) as unknown - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - events.push(parsed) - } - } catch { - // Skip malformed jq lines; timeline activity is auxiliary to issue details. - } - } - return events -} - -async function getIssueTimelineItems( - ownerRepo: GitHubApiRepository, - issueNumber: number, - ghOptions: ReturnType -): Promise { +async function withWorkItemDetailsPermit(operation: () => Promise): Promise { + await acquire() try { - const items: GitHubIssueTimelineItem[] = [] - for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return items - } - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, - '--jq', - '.[] | @json' - ], - { ...ghOptions, ...githubHostExecOptions(ownerRepo) } - ) - // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. - const pageEvents = parseRestTimelineEventLines(stdout) - for (const event of pageEvents) { - const item = mapRestTimelineEvent(event) - if (!item) { - continue - } - items.push(item) - if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { - break - } - } - if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { - break - } - } - return items - } catch { - return [] + return await operation() + } finally { + release() } } -/** - * Fetch an issue's body, comments, assignees, participants, and timeline in one GraphQL round-trip. - * Returns null on any partial error so the caller falls back to the strict REST path. - * Avatars are resolved here so GHE users don't render blank. - */ -async function getIssueDetailsViaGraphQL( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - // Avatar-bearing assignees, kept separate from the login-only `assignees`; enriches avatars `gh` leaves blank (GHE). - assigneeUsers: GitHubAssignableUser[] - participants: GitHubAssignableUser[] - timelineItems: GitHubIssueTimelineItem[] -} | null> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (!ownerRepo) { - return null - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${ISSUE_DETAILS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${issueNumber}` - ], - ghOptions - ) - const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse - if (parsed.errors && parsed.errors.length > 0) { - // Why: any partial GraphQL error forces the strict REST fallback so the drawer never paints a half-built shell. - return null - } - const issue = parsed.data?.repository?.issue - if (!issue) { - return null - } - const comments: PRComment[] = (issue.comments?.nodes ?? []) - .filter((c) => typeof c.databaseId === 'number') - .map((c) => ({ - id: c.databaseId as number, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body ?? '', - createdAt: c.createdAt ?? '', - url: c.url ?? '', - isBot: c.author?.__typename === 'Bot' - })) - const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) - .filter((a): a is { login: string; avatarUrl?: string; name?: string | null } => - Boolean(a.login) - ) - .map((a) => ({ - login: a.login, - name: a.name ?? null, - avatarUrl: a.avatarUrl ?? '' - })) - const assignees = assigneeUsers.map((a) => a.login) - const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) - .filter((u) => Boolean(u.login)) - .map((u) => ({ - login: u.login, - name: u.name ?? null, - avatarUrl: u.avatarUrl ?? '' - })) - const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - return { - body: issue.body ?? '', - comments, - assignees, - assigneeUsers, - participants, - timelineItems - } - } catch { - return null - } -} - -function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of users) { - if (!user.login) { - continue - } - const key = user.login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - // Why: return a new merged record instead of mutating caller-provided objects. - byLogin.set(key, { - login: existing.login, - name: existing.name ?? user.name ?? null, - avatarUrl: existing.avatarUrl || user.avatarUrl || '' - }) - continue - } - byLogin.set(key, { - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - }) - } - return Array.from(byLogin.values()) -} - -type RESTPRFile = { - filename: string - previous_filename?: string - status: string - additions: number - deletions: number - changes: number - /** Raw patch text when available; absent for binary files or patches over GitHub's size cap. */ - patch?: string -} - -function mapFileStatus(raw: string): GitHubPRFile['status'] { - switch (raw) { - case 'added': - return 'added' - case 'removed': - return 'removed' - case 'modified': - return 'modified' - case 'renamed': - return 'renamed' - case 'copied': - return 'copied' - case 'changed': - return 'changed' - case 'unchanged': - return 'unchanged' - default: - return 'modified' - } -} - -// Why: REST doesn't flag binaries but omits `patch` for them; treat "changes but no patch" as binary so the diff tab shows a placeholder. -function isBinaryHint(file: RESTPRFile): boolean { - if (file.status === 'removed' || file.status === 'added') { - // Added/removed file with changes but no patch is almost always binary (images, oversized lockfiles). - return file.patch === undefined && file.changes > 0 - } - return file.patch === undefined && file.changes > 0 -} - -async function getPRMetadata( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ body: string; headSha?: string; baseSha?: string }> { - if (!ownerRepo) { - // Why: a bare `gh pr view` can honor ambient GH_HOST/GH_REPO after hosted - // repository resolution fails, returning metadata for the wrong PR. - return { body: '' } - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '' } - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string | null - head?: { sha?: string } - base?: { sha?: string } - } - return { - body: data.body ?? '', - ...(data.head?.sha ? { headSha: data.head.sha } : {}), - ...(data.base?.sha ? { baseSha: data.base.sha } : {}) - } - } catch { - return { body: '' } - } -} - -// Why: null = failed/blocked fetch (Files tab shows retry, not "No files changed."); [] = genuinely empty PR. -async function getPRFiles( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - const data: RESTPRFile[] = [] - for (let page = 1; data.length < MAX_PR_FILES; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return null - } - const pageSuffix = page === 1 ? '' : `&page=${page}` - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` - ], - ghOptions - ) - const pageData = JSON.parse(stdout) as RESTPRFile[] - data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) - if (pageData.length < 100) { - break - } - } - return data.map((file) => ({ - path: file.filename, - oldPath: file.previous_filename, - status: mapFileStatus(file.status), - additions: file.additions, - deletions: file.deletions, - isBinary: isBinaryHint(file), - reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) - })) - } catch { - return null - } -} - -type PRFileViewedStatesResult = { - pullRequestId: string - viewedStates: Map -} - -async function getPRFileViewedStates( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - const viewedStates = new Map() - let pullRequestId: string | null = null - let after: string | null = null - - try { - for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { - const args = [ - 'api', - 'graphql', - '-f', - `query=${PR_FILE_VIEWED_STATES_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${prNumber}` - ] - if (after) { - args.push('-f', `after=${after}`) - } - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync(args, ghOptions) - const parsed = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - id?: string - files?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: { - path?: string | null - viewerViewedState?: GitHubPRFileViewedState | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] - } - if (parsed.errors && parsed.errors.length > 0) { - return null - } - const pullRequest = parsed.data?.repository?.pullRequest - if (!pullRequest?.id) { - return null - } - pullRequestId = pullRequest.id - for (const file of pullRequest.files?.nodes ?? []) { - if (file.path && file.viewerViewedState) { - viewedStates.set(file.path, file.viewerViewedState) - } - } - if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { - break - } - after = pullRequest.files.pageInfo.endCursor - } - } catch { - return null - } - - return pullRequestId ? { pullRequestId, viewedStates } : null -} - -function mergePRFileViewedStates( - files: GitHubPRFile[], - viewedStates: PRFileViewedStatesResult | null -): GitHubPRFile[] { - if (!viewedStates) { - return files - } - return files.map((file) => ({ - ...file, - viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' - })) -} - -async function getIssueBodyAndComments( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - timelineItems: GitHubIssueTimelineItem[] -}> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - if (ownerRepo) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Why: the fallback starts the issue and comments reads together; debit - // both before spawning so a failed response cannot leave quota overstated. - noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) - const [issueResult, commentsResult, timelineItems] = await Promise.all([ - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}` - ], - ghOptions - ), - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100` - ], - ghOptions - ), - getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - ]) - const issue = JSON.parse(issueResult.stdout) as { - body?: string | null - assignees?: { login: string }[] - } - type RESTComment = { - id: number - user: { login: string; avatar_url: string; type?: string } | null - body: string - created_at: string - html_url: string - } - const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( - (c): PRComment => ({ - id: c.id, - author: c.user?.login ?? 'ghost', - authorAvatarUrl: c.user?.avatar_url ?? '', - body: c.body ?? '', - createdAt: c.created_at, - url: c.html_url, - isBot: c.user?.type === 'Bot' - }) - ) - const assignees = (issue.assignees ?? []).map((a) => a.login) - return { body: issue.body ?? '', comments, assignees, timelineItems } - } - if (connectionId) { - // Why: connection-backed gh has no cwd. A bare issue lookup could honor - // process GH_REPO/GH_HOST and return an unrelated repository's issue. - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Fallback: non-GitHub remote - const { stdout } = await ghExecFileAsync( - ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string - comments?: { - author: { login: string } - body: string - createdAt: string - url: string - }[] - assignees?: { login: string }[] - } - const comments = (data.comments ?? []).map( - (c, i): PRComment => ({ - id: i, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: '', - body: c.body ?? '', - createdAt: c.createdAt, - url: c.url ?? '' - }) - ) - const fallbackAssignees = (data.assignees ?? []).map((a) => a.login) - return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] } - } catch { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } -} - -async function getWorkItemParticipants( - repoPath: string, - item: Pick, - resolvedRepository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: reuse the fan-out's repository identity so details cannot drift across hosts. - const ownerRepo = resolvedRepository - if (!ownerRepo) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return [] - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${item.number}`, - '-F', - `isPr=${item.type === 'pr'}` - ], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - issue?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - } - } - } - const nodes = - data.data?.repository?.pullRequest?.participants?.nodes ?? - data.data?.repository?.issue?.participants?.nodes ?? - [] - return nodes - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - .filter((user) => user.login) - } catch { - return [] - } -} - -async function getGitHubUsersByLogin( - repoPath: string, - logins: string[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!repository) { - return [] - } - const uniqueLogins = Array.from( - new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) - ).slice(0, 40) - if (uniqueLogins.length === 0) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(repository) - } - const guard = repositoryRateLimitGuard(repository, 'graphql', ghOptions) - if (guard.blocked) { - // Why: log the skip — callers degrade silently to blank GHE avatars, so it's otherwise untraceable. - console.warn( - `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` - ) - return [] - } - const fields = uniqueLogins - .map( - (login, index) => - `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` - ) - .join('\n') - try { - noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: Record< - string, - { - login?: string - name?: string | null - avatarUrl?: string | null - } | null - > - } - return Object.values(data.data ?? {}) - .filter( - ( - user - ): user is { - login: string - name?: string | null - avatarUrl?: string | null - } => Boolean(user?.login) - ) - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - } catch { - return [] - } -} - -/** - * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review requests, latest reviews, and assignees. - * - * Why: `gh pr view` omits avatar_url, so login-based avatars 404 on GHE; knownUsers carries the GraphQL-resolved ones. See #8784. - */ -function enrichItemDisplayAvatars( - item: Omit, - knownUsers: GitHubAssignableUser[] -): Omit { - const avatarByLogin = new Map() - for (const user of knownUsers) { - if (user.login && user.avatarUrl) { - avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) - } - } - if (avatarByLogin.size === 0) { - return item - } - // Why: prefer the GraphQL-resolved avatar — `gh pr view` returns empty/`u/0` placeholders for enterprise users; fall back to the original only when the lookup is empty. - const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) - const resolvedAvatar = (login: string, existing?: string | null): string | undefined => - avatarFor(login) || existing || undefined - // Callers coalesce a missing result to each field's "no avatar" sentinel ('' or null); GitHubUserAvatar falls back to login URL then initials. - const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl - return { - ...item, - ...(authorAvatarUrl ? { authorAvatarUrl } : {}), - ...(item.reviewRequests - ? { - reviewRequests: item.reviewRequests.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}), - ...(item.latestReviews - ? { - latestReviews: item.latestReviews.map((review) => ({ - ...review, - avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null - })) - } - : {}), - ...(item.assignees - ? { - assignees: item.assignees.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}) - } -} - -async function getMentionParticipants( - repoPath: string, - item: Pick< - GitHubWorkItem, - 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' - >, - comments: PRComment[], - participants: GitHubAssignableUser[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: resolve mention authors + display users (reviewers/assignees) in one aliased trip so avatars reuse it without a second rate-limited lookup (#8784). - // Why: order display users before comment authors so the 40-login cap in getGitHubUsersByLogin can't drop a reviewer/assignee avatar. - const visibleLogins = [ - item.author ?? '', - ...(item.reviewRequests ?? []).map((user) => user.login), - ...(item.latestReviews ?? []).map((review) => review.login), - ...(item.assignees ?? []).map((user) => user.login), - ...comments.map((comment) => comment.author) - ] - const graphQlUsers = await getGitHubUsersByLogin( - repoPath, - visibleLogins, - repository, - connectionId, - localGitOptions - ) - return mergeGitHubUsers([...participants, ...graphQlUsers]) -} - async function getPRChecksForDetails( repoPath: string, prNumber: number, @@ -1028,22 +57,12 @@ async function getPRChecksForDetails( connectionId, ...localGitOptionArgs(localGitOptions) ) - } catch (err) { - // Why: checks are auxiliary — a gh failure must not block opening the PR drawer. - console.warn('getWorkItemDetails PR checks failed:', err) + } catch (error) { + console.warn('getWorkItemDetails PR checks failed:', error) return [] } } -async function withWorkItemDetailsPermit(operation: () => Promise): Promise { - await acquire() - try { - return await operation() - } finally { - release() - } -} - export async function getWorkItemDetails( repoPath: string, number: number, @@ -1072,7 +91,6 @@ export async function getWorkItemDetails( if (item.type === 'issue') { return withWorkItemDetailsPermit(async () => { - // Why: one GraphQL trip returns all issue details; fall back to the legacy fan-out on failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, @@ -1082,7 +100,6 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - // Include assigneeUsers: non-participating assignees are absent from participants and keep a blank avatar (GHE). item: enrichItemDisplayAvatars(item, [ ...collapsed.participants, ...collapsed.assigneeUsers @@ -1094,7 +111,6 @@ export async function getWorkItemDetails( timelineItems: collapsed.timelineItems } } - // Fallback: fetch body/comments and participants in parallel. const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ getIssueBodyAndComments( repoPath, @@ -1125,8 +141,7 @@ export async function getWorkItemDetails( }) } - // Why: getPRComments and getPRChecks own their semaphore permits. Keeping an - // outer permit while awaiting either can deadlock four concurrent detail loads. + // Nested client reads own their semaphore permits, so they stay outside these permits. const [[metadata, files, viewedStates, participants], comments] = await Promise.all([ Promise.all([ withWorkItemDetailsPermit(() => @@ -1159,7 +174,6 @@ export async function getWorkItemDetails( : Promise.resolve([]) ]) - // Why: mention hydration spawns gh directly while checks owns a permit; bound them without nesting. const [mentionParticipants, checks] = await Promise.all([ withWorkItemDetailsPermit(() => getMentionParticipants( @@ -1193,57 +207,12 @@ export async function getWorkItemDetails( baseSha: metadata.baseSha, pullRequestId: viewedStates?.pullRequestId, checks, - // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), filesUnavailable: files === null, participants: mentionParticipants } } -// Why: Monaco DiffViewer needs original/modified text (not patches); --cache bounds rate-limit spend on rapid file-expands. -async function fetchContentAtRef(args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - ownerRepo: GitHubApiRepository - path: string - ref: string -}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), - ...githubHostExecOptions(args.ownerRepo), - maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES - } - if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { - return { content: '', isBinary: false } - } - try { - noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '300s', - '-H', - 'Accept: application/vnd.github.raw', - `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` - ], - ghOptions - ) - // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). - const sample = stdout.slice(0, 2048) - if (sample.includes('\u0000')) { - return { content: '', isBinary: true } - } - return { content: stdout, isBinary: false } - } catch (error) { - if (isMaxBufferOverflowError(error)) { - return { content: '', isBinary: false, tooLarge: true } - } - return { content: '', isBinary: false } - } -} - export async function getPRFileContents(args: { repoPath: string connectionId?: string | null @@ -1256,67 +225,5 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const { ownerRepo } = await resolveGitHubRepoExecution( - args.repoPath, - args.prRepo, - args.connectionId, - args.localGitOptions - ) - if (!ownerRepo) { - return { - original: '', - modified: '', - originalIsBinary: false, - modifiedIsBinary: false - } - } - - await acquire() - try { - // Why: added files have no base-ref original, removed files no head-ref modified; skip those to avoid spurious 404s. - const needsOriginal = args.status !== 'added' - const needsModified = args.status !== 'removed' - const originalRef = args.baseSha - const originalPath = args.oldPath ?? args.path - - const [original, modified] = await Promise.all([ - needsOriginal - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: originalPath, - ref: originalRef - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }), - needsModified - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: args.path, - ref: args.headSha - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }) - ]) - - return { - original: original.content, - modified: modified.content, - originalIsBinary: original.isBinary, - modifiedIsBinary: modified.isBinary, - originalTooLarge: original.tooLarge, - modifiedTooLarge: modified.tooLarge - } - } finally { - release() - } + return loadPRFileContents(args) } diff --git a/src/main/github/work-item-participants.ts b/src/main/github/work-item-participants.ts new file mode 100644 index 00000000000..0ac793b4812 --- /dev/null +++ b/src/main/github/work-item-participants.ts @@ -0,0 +1,241 @@ +import type { PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../shared/github/work-item-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) @include(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + issue(number: $number) @skip(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + } +}` + +function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of users) { + if (!user.login) { + continue + } + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (existing) { + byLogin.set(key, { + login: existing.login, + name: existing.name ?? user.name ?? null, + avatarUrl: existing.avatarUrl || user.avatarUrl || '' + }) + continue + } + byLogin.set(key, { + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + }) + } + return Array.from(byLogin.values()) +} + +export async function getWorkItemParticipants( + repoPath: string, + item: Pick, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return [] + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${item.number}`, + '-F', + `isPr=${item.type === 'pr'}` + ], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + issue?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + } + } + } + const nodes = + data.data?.repository?.pullRequest?.participants?.nodes ?? + data.data?.repository?.issue?.participants?.nodes ?? + [] + return nodes + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + .filter((user) => user.login) + } catch { + return [] + } +} + +async function getGitHubUsersByLogin( + repoPath: string, + logins: string[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const uniqueLogins = Array.from( + new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) + ).slice(0, 40) + if (uniqueLogins.length === 0) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + console.warn( + `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` + ) + return [] + } + const fields = uniqueLogins + .map( + (login, index) => + `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` + ) + .join('\n') + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', 'graphql', '-f', `query=query { ${fields} }`], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: Record< + string, + { login?: string; name?: string | null; avatarUrl?: string | null } | null + > + } + return Object.values(data.data ?? {}) + .filter((user): user is { login: string; name?: string | null; avatarUrl?: string | null } => + Boolean(user?.login) + ) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + } catch { + return [] + } +} + +export async function getMentionParticipants( + repoPath: string, + item: Pick< + GitHubWorkItem, + 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' + >, + comments: PRComment[], + participants: GitHubAssignableUser[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const visibleLogins = [ + item.author ?? '', + ...(item.reviewRequests ?? []).map((user) => user.login), + ...(item.latestReviews ?? []).map((review) => review.login), + ...(item.assignees ?? []).map((user) => user.login), + ...comments.map((comment) => comment.author) + ] + const graphQlUsers = await getGitHubUsersByLogin( + repoPath, + visibleLogins, + repository, + connectionId, + localGitOptions + ) + return mergeGitHubUsers([...participants, ...graphQlUsers]) +} + +export function enrichItemDisplayAvatars( + item: Omit, + knownUsers: GitHubAssignableUser[] +): Omit { + const avatarByLogin = new Map() + for (const user of knownUsers) { + if (user.login && user.avatarUrl) { + avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) + } + } + if (avatarByLogin.size === 0) { + return item + } + const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) + const resolvedAvatar = (login: string, existing?: string | null): string | undefined => + avatarFor(login) || existing || undefined + const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl + return { + ...item, + ...(authorAvatarUrl ? { authorAvatarUrl } : {}), + ...(item.reviewRequests + ? { + reviewRequests: item.reviewRequests.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}), + ...(item.latestReviews + ? { + latestReviews: item.latestReviews.map((review) => ({ + ...review, + avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null + })) + } + : {}), + ...(item.assignees + ? { + assignees: item.assignees.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}) + } +} diff --git a/src/main/ipc/github-account-handlers.ts b/src/main/ipc/github-account-handlers.ts new file mode 100644 index 00000000000..b5d91b6757a --- /dev/null +++ b/src/main/ipc/github-account-handlers.ts @@ -0,0 +1,31 @@ +import { ipcMain } from 'electron' +import { appStarSourceSchema } from '../../shared/gh-star-source' +import { diagnoseGhAuth } from '../github/auth-diagnose' +import { checkOrcaStarred, getAuthenticatedViewer, starOrca } from '../github/client' +import { getRateLimit } from '../github/rate-limit' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { track } from '../telemetry/client' + +export function registerGitHubAccountHandlers(): void { + ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) + ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) + ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { + const sourceParse = appStarSourceSchema.safeParse(source) + const starred = await starOrca() + if (starred && sourceParse.success) { + track('app_starred_orca', { + source: sourceParse.data, + ...getCohortAtEmit() + }) + } + return starred + }) + + ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => + getRateLimit(args?.force ? { force: true } : undefined) + ) + + ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => + diagnoseGhAuth(args?.host) + ) +} diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts new file mode 100644 index 00000000000..a54b7cfbf19 --- /dev/null +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createGitHubIpcMocks } = await import('./github-ipc-module-mocks') + return createGitHubIpcMocks() +}) + +vi.mock('electron', () => mocks.electron) +vi.mock('../github/client', () => mocks.client) +vi.mock('../github/work-item-details', () => mocks.workItemDetails) +vi.mock('../github/pr-refresh-coordinator', () => mocks.prRefresh) +vi.mock('../telemetry/client', () => mocks.telemetry) +vi.mock('../telemetry/cohort-classifier', () => mocks.cohort) +vi.mock('./ui', () => mocks.ui) + +import * as github from './github' +import { createGitHubIpcHarness } from './github-ipc-test-harness' + +const EXPECTED_GITHUB_IPC_CHANNELS = [ + 'gh:prForBranch', + 'gh:refreshPRNow', + 'gh:enqueuePRRefresh', + 'gh:reportVisiblePRRefreshCandidates', + 'gh:issue', + 'gh:listIssues', + 'gh:createIssue', + 'gh:listWorkItems', + 'gh:countWorkItems', + 'gh:workItem', + 'gh:workItemByOwnerRepo', + 'gh:workItemDetails', + 'gh:notifyWorkItemMutated', + 'gh:prFileContents', + 'gh:repoSlug', + 'gh:repoUpstream', + 'gh:prChecks', + 'gh:prCheckDetails', + 'gh:prComments', + 'gh:setPRCommentReaction', + 'gh:resolveReviewThread', + 'gh:setPRFileViewed', + 'gh:addPRReviewCommentReply', + 'gh:addPRReviewComment', + 'gh:updatePRTitle', + 'gh:mergePR', + 'gh:setPRAutoMerge', + 'gh:updatePRState', + 'gh:rerunPRChecks', + 'gh:requestPRReviewers', + 'gh:removePRReviewers', + 'gh:updateIssue', + 'gh:addIssueComment', + 'gh:listLabels', + 'gh:listAssignableUsers', + 'gh:viewer', + 'gh:checkOrcaStarred', + 'gh:starOrca', + 'gh:rateLimit', + 'gh:diagnoseAuth', + 'gh:listAccessibleProjects', + 'gh:resolveProjectRef', + 'gh:listProjectViews', + 'gh:getProjectViewTable', + 'gh:projectWorkItemDetailsBySlug', + 'gh:updateProjectItemField', + 'gh:clearProjectItemField', + 'gh:updateIssueBySlug', + 'gh:updatePullRequestBySlug', + 'gh:addIssueCommentBySlug', + 'gh:updateIssueCommentBySlug', + 'gh:deleteIssueCommentBySlug', + 'gh:listLabelsBySlug', + 'gh:listAssignableUsersBySlug', + 'gh:listIssueTypesBySlug', + 'gh:updateIssueTypeBySlug' +] as const + +describe('GitHub IPC channel parity', () => { + const harness = createGitHubIpcHarness(mocks) + + beforeEach(harness.reset) + + it('preserves the facade and fixed IPC channel contract', () => { + github.registerGitHubHandlers(harness.store as never, harness.stats as never) + + const preloadSource = readFileSync(new URL('../../preload/index.ts', import.meta.url), 'utf8') + const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( + (match) => match[1] + ) + const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( + ([channel]) => channel as string + ) + + expect(Object.keys(github)).toEqual(['registerGitHubHandlers']) + expect(new Set(registeredChannels).size).toBe(registeredChannels.length) + expect(registeredChannels).toEqual(EXPECTED_GITHUB_IPC_CHANNELS) + expect(registeredChannels.toSorted()).toEqual([...new Set(exposedChannels)].toSorted()) + }) +}) diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts new file mode 100644 index 00000000000..992f0c6e11f --- /dev/null +++ b/src/main/ipc/github-issue-mutation-handlers.ts @@ -0,0 +1,106 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addIssueComment, listAssignableUsers, listLabels, updateIssue } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubIssueMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updateIssue', + async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: 'Updates object is required' } + } + const result = await updateIssue( + repo.path, + args.number, + args.updates, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addIssueComment', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addIssueComment( + repo.path, + args.number, + args.body.trim(), + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { + repoPath: repo.path, + repoId: repo.id, + type: args.type ?? 'issue', + number: args.number + }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listLabels( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) +} diff --git a/src/main/ipc/github-pr-mutation-handlers.ts b/src/main/ipc/github-pr-mutation-handlers.ts new file mode 100644 index 00000000000..1d3aecc38e3 --- /dev/null +++ b/src/main/ipc/github-pr-mutation-handlers.ts @@ -0,0 +1,212 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' +import { + mergePR, + removePRReviewers, + requestPRReviewers, + rerunPRChecks, + setPRAutoMerge, + updatePRState, + updatePRTitle +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +function broadcastSuccessfulPRMutation( + ok: boolean, + repoPath: string, + repoId: string, + prNumber: number, + senderId: number +): void { + if (ok) { + broadcastGitHubWorkItemMutation({ repoPath, repoId, type: 'pr', number: prNumber }, senderId) + } +} + +export function registerGitHubPRMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updatePRTitle', + async ( + event, + args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const ok = await updatePRTitle( + repo.path, + args.prNumber, + args.title, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(ok, repo.path, repo.id, args.prNumber, event.sender.id) + return ok + } + ) + + ipcMain.handle( + 'gh:mergePR', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await mergePR( + repo.path, + args.prNumber, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:setPRAutoMerge', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await setPRAutoMerge( + repo.path, + args.prNumber, + args.enabled, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:updatePRState', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + updates: GitHubPullRequestStateUpdate + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + const result = await updatePRState( + repo.path, + args.prNumber, + args.updates, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:rerunPRChecks', + async ( + _event, + args: GitHubRepoScopedArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + return rerunPRChecks( + repo.path, + args.prNumber, + { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:requestPRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await requestPRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:removePRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await removePRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) +} diff --git a/src/main/ipc/github-pr-read-handlers.ts b/src/main/ipc/github-pr-read-handlers.ts new file mode 100644 index 00000000000..1c200218f91 --- /dev/null +++ b/src/main/ipc/github-pr-read-handlers.ts @@ -0,0 +1,178 @@ +import { ipcMain } from 'electron' +import type { GitHubReactionContent } from '../../shared/github/comment-types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + getPRCheckDetails, + getPRChecks, + getPRComments, + getRepoSlug, + getRepoUpstream, + resolveReviewThread, + setPRCommentReaction +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' + +export function registerGitHubPRReadHandlers(store: Store): void { + ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoSlug(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoSlug(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle( + 'gh:prChecks', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRChecks( + repo.path, + args.prNumber, + args.headSha, + args.prRepo ?? null, + { noCache: args.noCache }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prCheckDetails', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRCheckDetails( + repo.path, + { + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null + }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prComments', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRComments( + repo.path, + args.prNumber, + { noCache: args.noCache, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:setPRCommentReaction', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (!args.reactionSubjectId?.trim()) { + return false + } + return setPRCommentReaction( + repo.path, + args.reactionSubjectId.trim(), + args.content, + args.reacted, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:resolveReviewThread', + async ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return resolveReviewThread( + repo.path, + args.threadId, + args.resolve, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) +} diff --git a/src/main/ipc/github-pr-refresh-handlers.ts b/src/main/ipc/github-pr-refresh-handlers.ts new file mode 100644 index 00000000000..ab4dc74ec7d --- /dev/null +++ b/src/main/ipc/github-pr-refresh-handlers.ts @@ -0,0 +1,161 @@ +import { ipcMain } from 'electron' +import { resolve } from 'node:path' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import { getPRForBranch, type GitHubPRBranchLookupOptions } from '../github/client' +import { + clearVisiblePRRefreshWindow, + enqueuePRRefresh, + refreshPRNow, + reportVisiblePRRefreshCandidates, + setPRRefreshOutcomeObserver +} from '../github/pr-refresh-coordinator' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import { + applyRegisteredRepoToPRRefreshCandidate, + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + validateAutomaticPRRefreshCandidate +} from './github-repo-routing' + +const visibilityCleanupRegistered = new Set() + +export function registerGitHubPRRefreshHandlers(store: Store, stats: StatsCollector): void { + function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } + }) + } + } + + setPRRefreshOutcomeObserver((candidate, outcome) => { + const repo = + store.getRepos().find((entry) => entry.id === candidate.repoId) ?? + store.getRepos().find((entry) => resolve(entry.path) === resolve(candidate.repoPath)) + if (repo) { + recordPRIfNeeded(repo, outcome) + } + }) + + ipcMain.handle( + 'gh:prForBranch', + async ( + _event, + args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const hostedReviewOptions = localGitOptions + ? { localGitExecOptions: localGitOptions } + : undefined + const currentHeadOid = + typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 + ? args.currentHeadOid.trim() + : null + const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptions + ? { ...hostedReviewOptions } + : args.acceptMergedFallbackPR === true || currentHeadOid !== null + ? {} + : undefined + if (lookupOptions && args.acceptMergedFallbackPR === true) { + lookupOptions.acceptMergedFallbackPR = true + } + if (lookupOptions && currentHeadOid !== null) { + lookupOptions.currentHeadOid = currentHeadOid + } + const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions + ? [lookupOptions] + : [] + const pr = await getPRForBranch( + repo.path, + args.branch, + args.linkedPRNumber ?? null, + getGitHubRepoConnectionId(repo), + args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, + ...lookupOptionArgs + ) + if (pr && !stats.hasCountedPR(pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: pr.number, prUrl: pr.url } + }) + } + return pr + } + ) + + ipcMain.handle( + 'gh:refreshPRNow', + async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { + const repo = assertRegisteredGitHubRepo(args.candidate, store) + const outcome = await refreshPRNow( + applyRegisteredRepoToPRRefreshCandidate(store, repo, args.candidate) + ) + recordPRIfNeeded(repo, outcome) + return outcome + } + ) + + ipcMain.handle( + 'gh:enqueuePRRefresh', + ( + event, + args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + } + ): GitHubPRRefreshEnqueueResult => { + const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) + if (validation.kind === 'skipped') { + return validation.result + } + enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, event?.sender?.id) + return { kind: 'queued' } + } + ) + + ipcMain.handle( + 'gh:reportVisiblePRRefreshCandidates', + (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { + const senderId = event.sender.id + if (!visibilityCleanupRegistered.has(senderId)) { + visibilityCleanupRegistered.add(senderId) + event.sender.once('destroyed', () => { + visibilityCleanupRegistered.delete(senderId) + clearVisiblePRRefreshWindow(senderId) + }) + } + const candidates: GitHubPRRefreshCandidate[] = [] + const repos = store.getRepos() + for (const candidate of args.candidates) { + const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) + if (validation.kind === 'ok') { + candidates.push(validation.candidate) + } + } + reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) + return true + } + ) +} diff --git a/src/main/ipc/github-pr-review-handlers.ts b/src/main/ipc/github-pr-review-handlers.ts new file mode 100644 index 00000000000..637eb74c1de --- /dev/null +++ b/src/main/ipc/github-pr-review-handlers.ts @@ -0,0 +1,180 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addPRReviewComment, addPRReviewCommentReply, setPRFileViewed } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubPRReviewHandlers(store: Store): void { + ipcMain.handle( + 'gh:setPRFileViewed', + async ( + event, + args: { + repoPath: string + repoId?: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return false + } + if (!args.pullRequestId?.trim() || !args.path?.trim()) { + return false + } + const ok = await setPRFileViewed({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + pullRequestId: args.pullRequestId.trim(), + path: args.path, + viewed: Boolean(args.viewed) + }) + if (ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return ok + } + ) + + ipcMain.handle( + 'gh:addPRReviewCommentReply', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if ( + typeof args.commentId !== 'number' || + !Number.isInteger(args.commentId) || + args.commentId < 1 + ) { + return { ok: false, error: 'Invalid comment ID' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewCommentReply( + repo.path, + args.prNumber, + args.commentId, + args.body.trim(), + args.threadId, + args.path, + args.line, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addPRReviewComment', + async ( + event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { + return { ok: false, error: 'Invalid line number' } + } + if ( + args.startLine !== undefined && + (typeof args.startLine !== 'number' || + !Number.isInteger(args.startLine) || + args.startLine < 1 || + args.startLine > args.line) + ) { + return { ok: false, error: 'Invalid start line' } + } + if (!args.commitId?.trim()) { + return { ok: false, error: 'Missing PR head SHA' } + } + if (!args.path?.trim()) { + return { ok: false, error: 'File path required' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewComment({ + repoPath: repo.path, + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + commitId: args.commitId.trim(), + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body.trim(), + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0] + }) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) +} diff --git a/src/main/ipc/github-project-view-handlers.ts b/src/main/ipc/github-project-view-handlers.ts new file mode 100644 index 00000000000..0106bd80bc7 --- /dev/null +++ b/src/main/ipc/github-project-view-handlers.ts @@ -0,0 +1,89 @@ +import { ipcMain } from 'electron' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAccessibleProjectsArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github/project-request-types' +import { + addIssueCommentBySlug, + clearProjectItemFieldValue, + deleteIssueCommentBySlug, + getProjectViewTable, + getWorkItemDetailsBySlug, + listAccessibleProjects, + listAssignableUsersBySlug, + listIssueTypesBySlug, + listLabelsBySlug, + listProjectViews, + resolveProjectRef, + updateIssueBySlug, + updateIssueCommentBySlug, + updateIssueTypeBySlug, + updateProjectItemFieldValue, + updatePullRequestBySlug +} from '../github/project-view' + +export function registerGitHubProjectViewHandlers(): void { + ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => + listAccessibleProjects(args) + ) + ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => + resolveProjectRef(args) + ) + ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => + listProjectViews(args) + ) + ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => + getProjectViewTable(args) + ) + ipcMain.handle( + 'gh:projectWorkItemDetailsBySlug', + (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) + ) + ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => + updateProjectItemFieldValue(args) + ) + ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => + clearProjectItemFieldValue(args) + ) + ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => + updateIssueBySlug(args) + ) + ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => + updatePullRequestBySlug(args) + ) + ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => + addIssueCommentBySlug(args) + ) + ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => + updateIssueCommentBySlug(args) + ) + ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => + deleteIssueCommentBySlug(args) + ) + ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => + listLabelsBySlug(args) + ) + ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => + listAssignableUsersBySlug(args) + ) + ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => + listIssueTypesBySlug(args) + ) + ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => + updateIssueTypeBySlug(args) + ) +} diff --git a/src/main/ipc/github-repo-routing.ts b/src/main/ipc/github-repo-routing.ts new file mode 100644 index 00000000000..a7f93c2b44f --- /dev/null +++ b/src/main/ipc/github-repo-routing.ts @@ -0,0 +1,130 @@ +import { resolve } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + notePRRefreshValidationDenial, + type PRRefreshValidationDenialReason +} from '../github/pr-refresh-validation-backoff' +import type { Store } from '../persistence' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' + +export type GitHubRepoScopedArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type RegisteredRepoValidationResult = + | { kind: 'ok'; repo: Repo } + | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } + +function validateRegisteredRepo( + args: string | GitHubRepoScopedArgs, + store: Store, + repos = store.getRepos() +): RegisteredRepoValidationResult { + const repoPath = typeof args === 'string' ? args : args.repoPath + const repoId = typeof args === 'string' ? undefined : args.repoId + const resolvedRepoPath = resolve(repoPath) + const repo = repos.find((candidate) => + repoId ? candidate.id === repoId : resolve(candidate.path) === resolvedRepoPath + ) + if (!repo) { + return { + kind: 'denied', + reason: 'unknown-repo', + message: 'Access denied: unknown repository path' + } + } + if (repoId && resolve(repo.path) !== resolvedRepoPath) { + return { + kind: 'denied', + reason: 'repo-path-mismatch', + message: 'Access denied: repository path does not match repo id' + } + } + if ( + typeof args !== 'string' && + args.sourceContext?.provider === 'github' && + args.sourceContext.hostId !== getRepoExecutionHostId(repo) + ) { + return { + kind: 'denied', + reason: 'host-mismatch', + message: 'Access denied: GitHub source host does not match repository host' + } + } + return { kind: 'ok', repo } +} + +export function assertRegisteredGitHubRepo( + args: string | GitHubRepoScopedArgs, + store: Store +): Repo { + const result = validateRegisteredRepo(args, store) + if (result.kind === 'denied') { + throw new Error(result.message) + } + return result.repo +} + +export function getGitHubRepoConnectionId(repo: Repo): string | null { + return repo.connectionId ?? null +} + +export function getGitHubLocalGitOptionArgs( + store: Store, + repo: Repo +): [] | [{ wslDistro?: string }] { + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] +} + +export function applyRegisteredRepoToPRRefreshCandidate( + store: Store, + repo: Repo, + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshCandidate { + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const appliedCandidate = { ...candidate } + delete appliedCandidate.localGitOptions + delete appliedCandidate.connectionId + delete appliedCandidate.executionHostId + delete appliedCandidate.connectionState + return { + ...appliedCandidate, + repoPath: repo.path, + repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), + connectionId: getGitHubRepoConnectionId(repo), + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId ? 'connected' : 'unknown' + } +} + +export function validateAutomaticPRRefreshCandidate( + candidate: GitHubPRRefreshCandidate, + store: Store, + repos = store.getRepos() +): + | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } + | { kind: 'skipped'; result: Extract } { + const result = validateRegisteredRepo(candidate, store, repos) + if (result.kind === 'denied') { + const skippedReason = notePRRefreshValidationDenial({ + repoId: candidate.repoId, + repoPath: candidate.repoPath, + reason: result.reason + }) + return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } + } + return { + kind: 'ok', + candidate: applyRegisteredRepoToPRRefreshCandidate(store, result.repo, candidate) + } +} diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts new file mode 100644 index 00000000000..26c21b3db81 --- /dev/null +++ b/src/main/ipc/github-work-item-handlers.ts @@ -0,0 +1,214 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' +import type { GitHubCreateIssueFields } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + countWorkItems, + createIssue, + getIssue, + getWorkItem, + getWorkItemByOwnerRepo, + listIssues, + listWorkItems +} from '../github/client' +import { getPRFileContents, getWorkItemDetails } from '../github/work-item-details' +import type { Store } from '../persistence' +import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubWorkItemHandlers(store: Store): void { + ipcMain.handle( + 'gh:issue', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getIssue( + repo.path, + args.number, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listIssues( + repo.path, + args.limit, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ).then((result) => result.items) + }) + + ipcMain.handle( + 'gh:createIssue', + ( + _event, + args: GitHubRepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const fields = + args.labels !== undefined || args.assignees !== undefined + ? { labels: args.labels, assignees: args.assignees } + : undefined + return createIssue( + repo.path, + args.title, + args.body, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + fields, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:listWorkItems', + ( + _event, + args: { + repoPath: string + repoId?: string + limit?: number + query?: string + page?: number + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listWorkItems( + repo.path, + args.limit, + args.query, + args.page, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + args.noCache, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return countWorkItems( + repo.path, + args.query, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem(args, repo, getWorkItem, getGitHubLocalGitOptionArgs(store, repo)[0]) + }) + + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getWorkItemByOwnerRepo( + repo.path, + { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, + args.number, + args.type, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem( + args, + repo, + getWorkItemDetails, + getGitHubLocalGitOptionArgs(store, repo)[0] + ) + }) + + ipcMain.handle( + 'gh:notifyWorkItemMutated', + (event, args: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number }) => { + const repo = args.repoId + ? store.getRepos().find((candidate) => candidate.id === args.repoId) + : assertRegisteredGitHubRepo(args, store) + if (!repo) { + return false + } + if ( + (args.type !== 'issue' && args.type !== 'pr') || + typeof args.number !== 'number' || + !Number.isInteger(args.number) || + args.number < 1 + ) { + return false + } + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: args.type, number: args.number }, + event.sender.id + ) + return true + } + ) + + ipcMain.handle( + 'gh:prFileContents', + ( + _event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRFileContents({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + path: args.path, + oldPath: args.oldPath, + status: args.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + } + ) +} diff --git a/src/main/ipc/github-work-item-mutation-events.ts b/src/main/ipc/github-work-item-mutation-events.ts new file mode 100644 index 00000000000..5466dd8bdc4 --- /dev/null +++ b/src/main/ipc/github-work-item-mutation-events.ts @@ -0,0 +1,15 @@ +import { sendToTrustedUIRenderer } from './ui' + +export type GitHubWorkItemMutation = { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +} + +export function broadcastGitHubWorkItemMutation( + payload: GitHubWorkItemMutation, + senderId?: number +): void { + sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) +} diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 7673aff06ce..668d9fdca79 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -1,1312 +1,21 @@ -/* eslint-disable max-lines -- Why: all GitHub IPC handlers stay co-located so -the repo-path validation, preference-threading, and stats wiring patterns are -reviewable as one surface. Splitting by feature area would risk drifting -validation/gate conventions across handler files. */ -import { ipcMain } from 'electron' -import { resolve } from 'node:path' -import type { GitHubReactionContent } from '../../shared/github/comment-types' -import type { - GitHubPRRefreshCandidate, - GitHubPRRefreshEnqueueResult, - GitHubPRRefreshReason, - PRRefreshOutcome -} from '../../shared/github/pull-request-refresh-types' -import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' -import type { - GitHubCreateIssueFields, - GitHubIssueUpdate, - GitHubPullRequestStateUpdate -} from '../../shared/issue-mutation-types' -import type { Repo } from '../../shared/repo-types' -import { getRepoExecutionHostId } from '../../shared/execution-host' -import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' -import { - getPRForBranch, - getIssue, - getRepoSlug, - getRepoUpstream, - listIssues, - listWorkItems, - countWorkItems, - getWorkItem, - getWorkItemByOwnerRepo, - createIssue, - updateIssue, - addIssueComment, - listLabels, - listAssignableUsers, - getAuthenticatedViewer, - getPRChecks, - getPRCheckDetails, - getPRComments, - setPRCommentReaction, - resolveReviewThread, - setPRFileViewed, - addPRReviewComment, - addPRReviewCommentReply, - updatePRTitle, - mergePR, - setPRAutoMerge, - updatePRState, - rerunPRChecks, - requestPRReviewers, - removePRReviewers, - checkOrcaStarred, - starOrca -} from '../github/client' -import type { GitHubPRBranchLookupOptions } from '../github/client' -import { - clearVisiblePRRefreshWindow, - enqueuePRRefresh, - refreshPRNow, - reportVisiblePRRefreshCandidates, - setPRRefreshOutcomeObserver -} from '../github/pr-refresh-coordinator' -import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' -import { getRateLimit } from '../github/rate-limit' -import { diagnoseGhAuth } from '../github/auth-diagnose' -import { - notePRRefreshValidationDenial, - type PRRefreshValidationDenialReason -} from '../github/pr-refresh-validation-backoff' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' -import { - getProjectViewTable, - listAccessibleProjects, - resolveProjectRef, - listProjectViews, - getWorkItemDetailsBySlug, - updateProjectItemFieldValue, - clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, - addIssueCommentBySlug, - updateIssueCommentBySlug, - deleteIssueCommentBySlug, - listLabelsBySlug, - listAssignableUsersBySlug, - listIssueTypesBySlug, - updateIssueTypeBySlug -} from '../github/project-view' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdateProjectItemFieldArgs, - UpdatePullRequestBySlugArgs -} from '../../shared/github/project-request-types' -import { appStarSourceSchema } from '../../shared/gh-star-source' -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { sendToTrustedUIRenderer } from './ui' - -const prRefreshVisibilityCleanupRegistered = new Set() - -// Why: the app renderer owns the SWR cache; browser guests cannot consume this -// event. Skip the origin because it already updated its cache optimistically. -function broadcastWorkItemMutated( - payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }, - senderId?: number -): void { - sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) -} - -// Why: returns the full Repo object instead of just the path string so that -// callers have access to repo.id for stat tracking and other context. -type RepoScopedArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type RegisteredRepoValidationResult = - | { kind: 'ok'; repo: Repo } - | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } - -function validateRegisteredRepo( - args: string | RepoScopedArgs, - store: Store, - repos = store.getRepos() -): RegisteredRepoValidationResult { - const repoPath = typeof args === 'string' ? args : args.repoPath - const repoId = typeof args === 'string' ? undefined : args.repoId - const resolvedRepoPath = resolve(repoPath) - const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) - if (!repo) { - return { - kind: 'denied', - reason: 'unknown-repo', - message: 'Access denied: unknown repository path' - } - } - if (repoId && resolve(repo.path) !== resolvedRepoPath) { - return { - kind: 'denied', - reason: 'repo-path-mismatch', - message: 'Access denied: repository path does not match repo id' - } - } - if ( - typeof args !== 'string' && - args.sourceContext?.provider === 'github' && - args.sourceContext.hostId !== getRepoExecutionHostId(repo) - ) { - return { - kind: 'denied', - reason: 'host-mismatch', - message: 'Access denied: GitHub source host does not match repository host' - } - } - return { kind: 'ok', repo } -} - -function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { - const result = validateRegisteredRepo(args, store) - if (result.kind === 'denied') { - throw new Error(result.message) - } - return result.repo -} - -function repoConnectionId(repo: Repo): string | null { - return repo.connectionId ?? null -} - -function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: string }] { - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] -} - -function applyRepoToPRRefreshCandidate( - store: Store, - repo: Repo, - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshCandidate { - const localGitOptions = localGitOptionArgs(store, repo)[0] - const appliedCandidate = { ...candidate } - delete appliedCandidate.localGitOptions - delete appliedCandidate.connectionId - delete appliedCandidate.executionHostId - delete appliedCandidate.connectionState - return { - ...appliedCandidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repoConnectionId(repo), - executionHostId: repo.executionHostId ?? null, - connectionState: repo.connectionId ? 'connected' : 'unknown' - } -} - -function validateAutomaticPRRefreshCandidate( - candidate: GitHubPRRefreshCandidate, - store: Store, - repos = store.getRepos() -): - | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } - | { - kind: 'skipped' - result: Extract - } { - const result = validateRegisteredRepo(candidate, store, repos) - if (result.kind === 'denied') { - const skippedReason = notePRRefreshValidationDenial({ - repoId: candidate.repoId, - repoPath: candidate.repoPath, - reason: result.reason - }) - return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } - } - return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) } -} +import { registerGitHubAccountHandlers } from './github-account-handlers' +import { registerGitHubIssueMutationHandlers } from './github-issue-mutation-handlers' +import { registerGitHubPRMutationHandlers } from './github-pr-mutation-handlers' +import { registerGitHubPRReadHandlers } from './github-pr-read-handlers' +import { registerGitHubPRRefreshHandlers } from './github-pr-refresh-handlers' +import { registerGitHubPRReviewHandlers } from './github-pr-review-handlers' +import { registerGitHubProjectViewHandlers } from './github-project-view-handlers' +import { registerGitHubWorkItemHandlers } from './github-work-item-handlers' export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { - function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } - }) - } - } - - setPRRefreshOutcomeObserver((candidate, outcome) => { - const repo = - store.getRepos().find((r) => r.id === candidate.repoId) ?? - store.getRepos().find((r) => resolve(r.path) === resolve(candidate.repoPath)) - if (repo) { - recordPRIfNeeded(repo, outcome) - } - }) - - ipcMain.handle( - 'gh:prForBranch', - async ( - _event, - args: { - repoPath: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] = - localGitOptions ? [{ localGitExecOptions: localGitOptions }] : [] - const currentHeadOid = - typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 - ? args.currentHeadOid.trim() - : null - const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0] - ? { ...hostedReviewOptionArgs[0] } - : args.acceptMergedFallbackPR === true || currentHeadOid !== null - ? {} - : undefined - if (lookupOptions && args.acceptMergedFallbackPR === true) { - lookupOptions.acceptMergedFallbackPR = true - } - if (lookupOptions && currentHeadOid !== null) { - lookupOptions.currentHeadOid = currentHeadOid - } - const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions - ? [lookupOptions] - : [] - const pr = await getPRForBranch( - repo.path, - args.branch, - args.linkedPRNumber ?? null, - repoConnectionId(repo), - args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, - ...lookupOptionArgs - ) - // Emit pr_created when a PR is first detected for a branch. - // Why here: the renderer polls gh:prForBranch to check PR status per worktree. - // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). - if (pr && !stats.hasCountedPR(pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: pr.number, prUrl: pr.url } - }) - } - return pr - } - ) - - ipcMain.handle( - 'gh:refreshPRNow', - async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { - const repo = assertRegisteredRepo(args.candidate, store) - const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate)) - recordPRIfNeeded(repo, outcome) - return outcome - } - ) - - ipcMain.handle( - 'gh:enqueuePRRefresh', - ( - event, - args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - } - ): GitHubPRRefreshEnqueueResult => { - const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) - if (validation.kind === 'skipped') { - return validation.result - } - const senderWindowId = event?.sender?.id - enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, senderWindowId) - return { kind: 'queued' } - } - ) - - ipcMain.handle( - 'gh:reportVisiblePRRefreshCandidates', - (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { - const senderId = event.sender.id - if (!prRefreshVisibilityCleanupRegistered.has(senderId)) { - prRefreshVisibilityCleanupRegistered.add(senderId) - event.sender.once('destroyed', () => { - prRefreshVisibilityCleanupRegistered.delete(senderId) - clearVisiblePRRefreshWindow(senderId) - }) - } - const candidates: GitHubPRRefreshCandidate[] = [] - const repos = store.getRepos() - for (const candidate of args.candidates) { - const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) - if (validation.kind === 'ok') { - candidates.push(validation.candidate) - } - } - reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) - return true - } - ) - - ipcMain.handle( - 'gh:issue', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - number: number - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getIssue( - repo.path, - args.number, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { - const repo = assertRegisteredRepo(args, store) - // Why: listIssues now returns { items, error? }. The IPC handler unwraps to - // the items array for the existing contract; feature 1's UI consumes the - // richer envelope through `gh:listWorkItems` instead. - return listIssues( - repo.path, - args.limit, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ).then((r) => r.items) - }) - - ipcMain.handle( - 'gh:createIssue', - (_event, args: RepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields) => { - const repo = assertRegisteredRepo(args, store) - const fields = - args.labels !== undefined || args.assignees !== undefined - ? { labels: args.labels, assignees: args.assignees } - : undefined - return createIssue( - repo.path, - args.title, - args.body, - repo.issueSourcePreference, - repoConnectionId(repo), - fields, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:listWorkItems', - ( - _event, - args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return listWorkItems( - repo.path, - args.limit, - args.query, - args.page, - repo.issueSourcePreference, - repoConnectionId(repo), - args.noCache, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { - const repo = assertRegisteredRepo(args, store) - return countWorkItems( - repo.path, - args.query, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItem, localGitOptionArgs(store, repo)[0]) - }) - ipcMain.handle( - 'gh:workItemByOwnerRepo', - ( - _event, - args: { - repoPath: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getWorkItemByOwnerRepo( - repo.path, - // Why: Enterprise host identity must survive the IPC boundary or the - // lookup falls back to gh's default host for a same-named repo. - { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, - args.number, - args.type, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItemDetails, localGitOptionArgs(store, repo)[0]) - }) - - ipcMain.handle( - 'gh:notifyWorkItemMutated', - ( - event, - args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - } - ) => { - const repo = args.repoId - ? store.getRepos().find((candidate) => candidate.id === args.repoId) - : assertRegisteredRepo(args, store) - if (!repo) { - return false - } - if ( - (args.type !== 'issue' && args.type !== 'pr') || - typeof args.number !== 'number' || - !Number.isInteger(args.number) || - args.number < 1 - ) { - return false - } - broadcastWorkItemMutated( - { - repoPath: repo.path, - repoId: repo.id, - type: args.type, - number: args.number - }, - event.sender.id - ) - return true - } - ) - - ipcMain.handle( - 'gh:prFileContents', - ( - _event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRFileContents({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - path: args.path, - oldPath: args.oldPath, - status: args.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - } - ) - - ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoSlug(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoSlug(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoUpstream(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoUpstream(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle( - 'gh:prChecks', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRChecks( - repo.path, - args.prNumber, - args.headSha, - args.prRepo ?? null, - { - noCache: args.noCache - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prCheckDetails', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRCheckDetails( - repo.path, - { - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prComments', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRComments( - repo.path, - args.prNumber, - { noCache: args.noCache, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRCommentReaction', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (!args.reactionSubjectId?.trim()) { - return false - } - return setPRCommentReaction( - repo.path, - args.reactionSubjectId.trim(), - args.content, - args.reacted, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:resolveReviewThread', - async ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - // Why: thread resolve doesn't carry the PR number, so we cannot target - // a specific cache entry. The renderer cache stores per-(repo, type, number) - // entries — emitting a path-wide invalidation here would require a new - // event shape; instead, the drawer's existing thread-resolve UI updates - // its local state immediately and the next reopen pays one fresh fetch. - return resolveReviewThread( - repo.path, - args.threadId, - args.resolve, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRFileViewed', - async ( - event, - args: { - repoPath: string - repoId?: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return false - } - if (!args.pullRequestId?.trim() || !args.path?.trim()) { - return false - } - const ok = await setPRFileViewed({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId.trim(), - path: args.path, - viewed: Boolean(args.viewed) - }) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:addPRReviewCommentReply', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if ( - typeof args.commentId !== 'number' || - !Number.isInteger(args.commentId) || - args.commentId < 1 - ) { - return { ok: false, error: 'Invalid comment ID' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewCommentReply( - repo.path, - args.prNumber, - args.commentId, - args.body.trim(), - args.threadId, - args.path, - args.line, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addPRReviewComment', - async ( - event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { - return { ok: false, error: 'Invalid line number' } - } - if ( - args.startLine !== undefined && - (typeof args.startLine !== 'number' || - !Number.isInteger(args.startLine) || - args.startLine < 1 || - args.startLine > args.line) - ) { - return { ok: false, error: 'Invalid start line' } - } - if (!args.commitId?.trim()) { - return { ok: false, error: 'Missing PR head SHA' } - } - if (!args.path?.trim()) { - return { ok: false, error: 'File path required' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewComment({ - repoPath: repo.path, - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - commitId: args.commitId.trim(), - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body.trim(), - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0] - }) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRTitle', - async ( - event, - args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } - ) => { - const repo = assertRegisteredRepo(args, store) - const ok = await updatePRTitle( - repo.path, - args.prNumber, - args.title, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:mergePR', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await mergePR( - repo.path, - args.prNumber, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:setPRAutoMerge', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await setPRAutoMerge( - repo.path, - args.prNumber, - args.enabled, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRState', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - updates: GitHubPullRequestStateUpdate - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - const result = await updatePRState( - repo.path, - args.prNumber, - args.updates, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:rerunPRChecks', - async ( - _event, - args: RepoScopedArgs & { - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - return rerunPRChecks( - repo.path, - args.prNumber, - { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:requestPRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await requestPRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:removePRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await removePRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updateIssue', - async (event, args: RepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.updates || typeof args.updates !== 'object') { - return { ok: false, error: 'Updates object is required' } - } - const result = await updateIssue( - repo.path, - args.number, - args.updates, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addIssueComment', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addIssueComment( - repo.path, - args.number, - args.body.trim(), - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - // Why: PR conversation comments hit `/issues/N/comments` too, but the - // drawer's cache key uses type='pr'. The caller passes through which - // drawer they're posting from so we only invalidate the matching key - // — broadcasting both would evict an unrelated PR/issue that happens - // to share the number. - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: args.type ?? 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listLabels( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listAssignableUsers( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - // Star operations target the Orca repo itself — no repoPath validation needed - ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) - ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) - ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { - const sourceParse = appStarSourceSchema.safeParse(source) - const starred = await starOrca() - if (starred && sourceParse.success) { - // Why: this main-owned event bypasses renderer telemetry IPC, so cohort - // context must be attached here on the successful star path. - track('app_starred_orca', { - source: sourceParse.data, - ...getCohortAtEmit() - }) - } - return starred - }) - - // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so - // polling is cheap. A 30s in-process cache still avoids the gh subprocess - // cost on every render — see getRateLimit for the ttl rationale. Force - // parameter lets the renderer bust the cache after a known-expensive op - // (e.g. post-ProjectPicker discovery) without waiting out the ttl. - ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => - getRateLimit(args?.force ? { force: true } : undefined) - ) - - ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => - diagnoseGhAuth(args?.host) - ) - - // ── GitHub ProjectV2 view handlers ───────────────────────────────── - // Why: registered unconditionally so enabling the experimental flag at - // runtime takes effect without a restart. The renderer gates entry points. - // Handlers never throw across IPC — every failure mode resolves through the - // GitHubProjectViewError envelope. - - ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => - listAccessibleProjects(args) - ) - - ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => - resolveProjectRef(args) - ) - - ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => - listProjectViews(args) - ) - - ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => - getProjectViewTable(args) - ) - - ipcMain.handle( - 'gh:projectWorkItemDetailsBySlug', - (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) - ) - - ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => - updateProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => - clearProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => - updateIssueBySlug(args) - ) - - ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => - updatePullRequestBySlug(args) - ) - - ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => - addIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => - updateIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => - deleteIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => - listLabelsBySlug(args) - ) - - ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => - listAssignableUsersBySlug(args) - ) - - ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => - listIssueTypesBySlug(args) - ) - - ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => - updateIssueTypeBySlug(args) - ) - - // Why: issue-source preference writes go through the generic `repos:update` - // IPC (extended in this PR to accept `issueSourcePreference`). Routing - // through the same channel keeps a single write path, guarantees the - // `repos:changed` broadcast is emitted, and avoids two channels racing to - // persist the same field with different validation and eviction semantics. - // Reads piggyback on the `Repo` record already delivered by `repos:list`. + registerGitHubPRRefreshHandlers(store, stats) + registerGitHubWorkItemHandlers(store) + registerGitHubPRReadHandlers(store) + registerGitHubPRReviewHandlers(store) + registerGitHubPRMutationHandlers(store) + registerGitHubIssueMutationHandlers(store) + registerGitHubAccountHandlers() + registerGitHubProjectViewHandlers() } diff --git a/src/main/ipc/pty/pane/stable-owner-attach.ts b/src/main/ipc/pty/pane/stable-owner-attach.ts index f1402de0f6e..3b35eb048d4 100644 --- a/src/main/ipc/pty/pane/stable-owner-attach.ts +++ b/src/main/ipc/pty/pane/stable-owner-attach.ts @@ -77,15 +77,15 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } - runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) - clearProviderPtyState(owner.ptyId) - ptyOwnership.delete(owner.ptyId) if ( args.worktreeId && !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) ) { throw new Error('terminal_pane_owner_changed') } + runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) + clearProviderPtyState(owner.ptyId) + ptyOwnership.delete(owner.ptyId) if (args.resolveOwner?.()) { throw new Error('terminal_pane_owner_changed') } diff --git a/src/main/ipc/pty/pane/stable-owner-retirement.test.ts b/src/main/ipc/pty/pane/stable-owner-retirement.test.ts new file mode 100644 index 00000000000..1f72511da71 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-owner-retirement.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalOwnerIdentity } from '../../../../shared/terminal-owner-identity' +import { TerminalSessionExitedError } from '../../../daemon/daemon-errors' +import { attachStablePaneOwner } from './stable-owner-attach' +import { retirePersistedStablePaneOwner, type StablePaneOwner } from './stable-owner' + +const worktreeId = 'repo-1::/tmp/owner-retirement' +const tabId = 'tab-owner-retirement' +const leafId = '11111111-1111-4111-8111-111111111111' +const paneKey = `${tabId}:${leafId}` + +function ownerIdentity(ownerIncarnationId: string): TerminalOwnerIdentity { + return { + executionHostId: 'local', + ownerKind: 'daemon', + ownerIncarnationId, + sessionIncarnationId: 'session-a', + protocolVersion: 37, + endpointRef: 'daemon-v37' + } +} + +function session(identity = ownerIdentity('daemon-a')) { + return { + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-a' }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-a' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'session-a' }, + terminalPtyOwnersByPaneKey: { [paneKey]: identity }, + terminalTopologyRevisionByRepoId: {} + } +} + +function stableOwner(): StablePaneOwner { + return { + tabId, + leafId, + ptyId: 'pty-a', + incarnationId: 'session-a', + persistedIncarnationId: 'session-a', + hasPersistedBinding: true, + ownerIdentity: ownerIdentity('daemon-a') + } +} + +describe('stable pane owner retirement', () => { + it('rejects a replacement owner even when logical and session ids match', () => { + const currentSession = session(ownerIdentity('daemon-b')) + const store = { + getWorkspaceSession: vi.fn(() => currentSession), + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn() + } + + expect(retirePersistedStablePaneOwner(store as never, stableOwner(), worktreeId, null)).toBe( + false + ) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.flushOrThrow).not.toHaveBeenCalled() + }) + + it('commits the persisted owner retirement before clearing runtime state', async () => { + const order: string[] = [] + let currentSession = session() + const store = { + getWorkspaceSession: vi.fn(() => currentSession), + setWorkspaceSession: vi.fn((next) => { + order.push('persist') + currentSession = next + }), + flushOrThrow: vi.fn(() => order.push('flush')) + } + const runtime = { + onPtyExit: vi.fn(() => order.push('runtime-exit')) + } + const provider = { + spawn: vi.fn(async () => { + throw new TerminalSessionExitedError('pty-a') + }) + } + + await expect( + attachStablePaneOwner({ + runtime: runtime as never, + store: store as never, + provider: provider as never, + spawnOptions: { cols: 80, rows: 24 }, + owner: stableOwner(), + worktreeId, + connectionId: null + }) + ).resolves.toBeNull() + + expect(order).toEqual(['persist', 'flush', 'runtime-exit']) + }) + + it('restores persisted topology and preserves runtime state when flush fails', async () => { + const originalSession = session() + let currentSession = originalSession + const store = { + getWorkspaceSession: vi.fn(() => currentSession), + setWorkspaceSession: vi.fn((next) => { + currentSession = next + }), + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) + } + const runtime = { onPtyExit: vi.fn() } + const provider = { + spawn: vi.fn(async () => { + throw new TerminalSessionExitedError('pty-a') + }) + } + + await expect( + attachStablePaneOwner({ + runtime: runtime as never, + store: store as never, + provider: provider as never, + spawnOptions: { cols: 80, rows: 24 }, + owner: stableOwner(), + worktreeId, + connectionId: null + }) + ).rejects.toThrow('disk unavailable') + + expect(currentSession).toBe(originalSession) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 4def65b95b3..95adf2fb226 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,6 +1,9 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalOwnerIdentity } from '../../../../shared/terminal-owner-identity' +import { + sameTerminalOwnerIdentity, + type TerminalOwnerIdentity +} from '../../../../shared/terminal-owner-identity' import type { Store } from '../../../persistence' import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' @@ -135,7 +138,14 @@ export function retirePersistedStablePaneOwner( // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. return true } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + const ownerIdentityMatches = owner.ownerIdentity + ? sameTerminalOwnerIdentity(current.ownerIdentity, owner.ownerIdentity) + : current.ownerIdentity === undefined + if ( + current.ptyId !== owner.ptyId || + current.incarnationId !== owner.persistedIncarnationId || + !ownerIdentityMatches + ) { return false } const session = store.getWorkspaceSession(hostId) @@ -150,7 +160,12 @@ export function retirePersistedStablePaneOwner( return false } store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() + try { + store.flushOrThrow() + } catch (error) { + store.setWorkspaceSession(session, hostId) + throw error + } return true } diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts new file mode 100644 index 00000000000..dafb6deadee --- /dev/null +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { PersistedState } from '../shared/persisted-state-types' +import type { Store as PersistenceStore } from './persistence/loading-store/store' +import { createStore, dataFile, makeRepo, testState } from './persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +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 })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +describe('loading Store extraction seams', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-loading-store-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8') + writeFileSync(`${dataFile()}.bak.0`, '{}', 'utf-8') + writeFileSync( + `${dataFile()}.bak.1`, + JSON.stringify({ repos: [makeRepo({ id: 'older-complete-profile' })] }), + 'utf-8' + ) + + const store = await createStore() + + expect(store.getRepos()).toEqual([]) + expect(readFileSync(dataFile(), 'utf-8')).toBe('{}') + }) + + it('leaves backup bytes reusable when publishing recovery to the primary path fails', async () => { + mkdirSync(dataFile(), { recursive: true }) + writeFileSync( + `${dataFile()}.bak.0`, + JSON.stringify({ repos: [makeRepo({ id: 'recovery-survives-publish-failure' })] }), + 'utf-8' + ) + + const failedRecovery = await createStore() + expect(failedRecovery.getRepos()).toEqual([]) + failedRecovery.freezeWrites() + expect(existsSync(`${dataFile()}.bak.0`)).toBe(true) + + rmSync(dataFile(), { recursive: true, force: true }) + const recovered = await createStore() + expect(recovered.getRepos().map((repo) => repo.id)).toEqual([ + 'recovery-survives-publish-failure' + ]) + }) + + it('aliases blank host reads, writes, and patches to the local disk partition', async () => { + const store = await createStore() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeRepoId: 'from-blank-set' }, + ' ' + ) + store.patchWorkspaceSession({ activeRepoId: 'from-blank-patch' }, '') + store.flushOrThrow() + + expect(store.getWorkspaceSession(' ').activeRepoId).toBe('from-blank-patch') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.workspaceSession?.activeRepoId).toBe('from-blank-patch') + expect(persisted.workspaceSessionsByHostId).toEqual({}) + }) + + it('keeps the last constructed Store as the global pane-migration listener owner', async () => { + const first = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const { setMigrationUnsupportedPty } = + await import('./agent-hooks/migration-unsupported-pty-state') + const secondDataFile = join(testState.dir, 'second-profile', 'orca-data.json') + const second = new Store({ dataFile: secondDataFile }) + + setMigrationUnsupportedPty({ + ptyId: 'listener-owner-pty', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 123 + }) + first.flushOrThrow() + second.flushOrThrow() + + const firstState = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + const secondState = JSON.parse(readFileSync(secondDataFile, 'utf-8')) as PersistedState + expect( + firstState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(false) + expect( + secondState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(true) + }) + + it('latches final flush bytes and ignores later in-memory mutations', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 731 }) + const finalFlush = store.flushAsync() + await finalFlush + + store.updateUI({ sidebarWidth: 732 }) + expect(store.flushAsync()).toBe(finalFlush) + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(731) + expect(store.getUI().sidebarWidth).toBe(732) + }) + + it('includes a same-tick mutation made after final flush is invoked', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 741 }) + + const finalFlush = store.flushAsync() + store.updateUI({ sidebarWidth: 742 }) + await finalFlush + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(742) + }) + + it('keeps delegated operations on the Store prototype with native receiver and override semantics', async () => { + const store = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const descriptor = Object.getOwnPropertyDescriptor(Store.prototype, 'getRepoCount') + + expect(descriptor).toMatchObject({ + configurable: true, + enumerable: false, + writable: true + }) + expect(Object.hasOwn(store, 'getRepoCount')).toBe(false) + const detached = store.getRepoCount + expect(() => detached()).toThrow(TypeError) + expect(detached.call(store)).toBe(0) + expect(new Proxy(store, {}).getRepoCount()).toBe(0) + expect([ + Store.prototype.getRepo.length, + Store.prototype.updateProject.length, + Store.prototype.createAutomationRun.length + ]).toEqual([1, 2, 2]) + + const prototypeSpy = vi.spyOn(Store.prototype, 'getRepoCount') + try { + expect(store.getRepoCount()).toBe(0) + expect(prototypeSpy).toHaveBeenCalledOnce() + } finally { + prototypeSpy.mockRestore() + } + + class StoreWithRepoCountOverride extends Store { + override getRepoCount(): number { + return 47 + } + } + const overridden = new StoreWithRepoCountOverride({ + dataFile: join(testState.dir, 'override-profile', 'orca-data.json') + }) + expect(overridden.getRepoCount()).toBe(47) + expectTypeOf().not.toHaveProperty('scheduleSave') + expectTypeOf().not.toHaveProperty('enqueueWrite') + expectTypeOf().not.toHaveProperty('getProjectHostOperations') + expectTypeOf().not.toHaveProperty('getAutomationDefinitionOperations') + expectTypeOf().not.toHaveProperty('getSshTargetStateOperations') + }) +}) diff --git a/src/main/persistence-loading-store-write-risks.test.ts b/src/main/persistence-loading-store-write-risks.test.ts new file mode 100644 index 00000000000..422573516f1 --- /dev/null +++ b/src/main/persistence-loading-store-write-risks.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' + +const testState = { dir: '' } + +const writeControl = vi.hoisted(() => { + let releaseRename: (() => void) | null = null + let markRenameStarted: (() => void) | null = null + return { + blockPrimaryRename: false, + failPrimaryOpen: false, + renameStarted: Promise.resolve(), + renameRelease: Promise.resolve(), + reset(): void { + this.blockPrimaryRename = false + this.failPrimaryOpen = false + this.renameStarted = new Promise((resolve) => { + markRenameStarted = resolve + }) + this.renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + }, + markRenameStarted(): void { + markRenameStarted?.() + }, + releaseRename(): void { + releaseRename?.() + } + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = String(args[0]) + if ( + writeControl.failPrimaryOpen && + target.includes('orca-data.json.') && + target.endsWith('.tmp') + ) { + throw Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) + } + return actual.open(...args) + }, + rename: async (...args: Parameters) => { + const target = String(args[1]) + if (writeControl.blockPrimaryRename && target.endsWith('orca-data.json')) { + writeControl.markRenameStarted() + await writeControl.renameRelease + } + return actual.rename(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +function createStore(): Store { + installFakeAppEnvironment({ getPath: () => testState.dir }) + initDataPath() + return new Store({ dataFile: join(testState.dir, 'orca-data.json') }) +} + +describe('loading Store write-risk characterization', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-write-risk-')) + writeControl.reset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('allows an already-started primary rename to publish after writes are frozen', async () => { + const store = await createStore() + writeControl.blockPrimaryRename = true + store.updateUI({ sidebarWidth: 712 }) + vi.advanceTimersByTime(1_000) + await writeControl.renameStarted + + store.freezeWrites() + writeControl.releaseRename() + await store.waitForPendingWrite() + + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + ui: { sidebarWidth: number } + } + expect(persisted.ui.sidebarWidth).toBe(712) + }) + + it('keeps a rejected durable mutation in memory for a later unrelated flush', async () => { + const store = await createStore() + writeControl.failPrimaryOpen = true + await expect( + store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + }) + ).rejects.toThrow('profile mount rejected write') + + writeControl.failPrimaryOpen = false + store.updateUI({ sidebarWidth: 713 }) + await store.flushPendingOrThrowAsync() + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + sshPtyConsumerRecoveries: { clientInstanceId: string }[] + } + expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-1') + }) +}) diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 89f5f03a88f..7b628b4b678 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,26 +1,22 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { vi } from 'vitest' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' // Shared mutable state so the electron mock can reference a per-test directory export const testState = { dir: '' } -/** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ -export async function createStore() { - vi.resetModules() - // Why here and not a per-file vi.mock('electron'): the data-file path resolves through - // AppEnvironment now, and it must point at this test's dir rather than the global - // fake's shared one. Re-installed after resetModules so the fresh graph sees it. +/** Create a profile store without rebuilding its large module graph inside each test timeout. */ +export function createStore(): Store { installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return new Store({ dataFile: dataFile() }) } export async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index f57a2a1b603..29c664be49b 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' @@ -8,6 +9,7 @@ import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser import { testState, createStore, + dataFile, writeDataFile, makeRepo, makeTerminalTab @@ -19,6 +21,7 @@ import { makeSessionWithBrowserHistory } from './persistence-session-fixtures' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store, initDataPath } from './persistence' // 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(() => ({ @@ -26,6 +29,29 @@ const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => sshConfigHostsToTargetsMock: vi.fn() })) +const asyncPrimaryWriteFailure = vi.hoisted(() => ({ + error: null as Error | null, + targetPrefix: '' +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = args[0] + if ( + asyncPrimaryWriteFailure.error && + typeof target === 'string' && + target.startsWith(asyncPrimaryWriteFailure.targetPrefix) + ) { + throw asyncPrimaryWriteFailure.error + } + return actual.open(...args) + } + } +}) + vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: loadUserSshConfigMock, sshConfigHostsToTargets: sshConfigHostsToTargetsMock @@ -66,10 +92,14 @@ describe('Store', () => { trackMock.mockReset() getCohortAtEmitMock.mockReset() getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) // ── GitHub Cache ─────────────────────────────────────────────────── @@ -188,11 +218,9 @@ describe('Store', () => { const profileDataFile = join(profileDataDirectory, 'orca-data.json') mkdirSync(profileDataDirectory, { recursive: true }) - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) @@ -221,11 +249,9 @@ describe('Store', () => { mkdirSync(legacySnapshotDir, { recursive: true }) writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) @@ -305,6 +331,7 @@ describe('Store', () => { throw new Error('expected scrollback snapshot ref') } expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true) + store.flushOrThrow() store.setWorkspaceSession({ activeRepoId: null, @@ -315,6 +342,74 @@ describe('Store', () => { }) expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) + const stillPublished = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) + }) + + it('leaves durable JSON pointing at deleted scrollback when the replacement write fails', 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 + ] + if (!ref) { + throw new Error('expected scrollback snapshot ref') + } + const snapshotPath = join(testState.dir, 'terminal-scrollback', `${ref}.bin`) + store.flushOrThrow() + const durableBeforeRemoval = readFileSync(dataFile(), 'utf-8') + expect(existsSync(snapshotPath)).toBe(true) + + const writeError = Object.assign(new Error('profile mount rejected replacement write'), { + code: 'EIO' + }) + asyncPrimaryWriteFailure.error = writeError + asyncPrimaryWriteFailure.targetPrefix = `${dataFile()}.` + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + store.setWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + + expect(existsSync(snapshotPath)).toBe(false) + expect(store.getWorkspaceSession().terminalLayoutsByTabId).toEqual({}) + await expect(store.flushPendingOrThrowAsync()).rejects.toBe(writeError) + } finally { + errors.mockRestore() + } + + expect(readFileSync(dataFile(), 'utf-8')).toBe(durableBeforeRemoval) + const stillPublished = JSON.parse(durableBeforeRemoval) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) }) it('reads only the replay tail from oversized terminal scrollback snapshots', async () => { diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 935f11382b3..2a29bc64f66 100644 --- a/src/main/persistence/applying-settings/feature-interaction-recording.ts +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -1,5 +1,4 @@ import type { PersistedState } from '../../../shared/persisted-state-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { compareFeatureInteractionUsageBuckets, getFeatureInteractionCategory, @@ -12,7 +11,7 @@ import { track } from '../../telemetry/client' import { getCohortAtEmit } from '../../telemetry/cohort-classifier' export type FeatureInteractionOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void notifyUIChanged: () => void getUI: () => PersistedState['ui'] diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index e6b18da0644..ff4c55b09a8 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -16,7 +16,7 @@ import { normalizeAppIconId } from '../../../shared/app-icon' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { addMobilePairingCustomAddress, normalizeMobilePairingCustomAddress, @@ -39,7 +39,7 @@ import { } from './terminal-settings-migrations' export type SettingsMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index f757fa5a1c4..b93ddfb1296 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -24,7 +24,6 @@ import { normalizeContextualTourIds } from '../../../shared/contextual-tours' import { normalizeFeatureInteractions } from '../../../shared/feature-interactions' import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' import { persistedUIValuesEqual } from '../../../shared/persisted-ui-equality' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { PROTECTED_SECRET_SLOT, type ProtectedSecretPersistence @@ -44,7 +43,7 @@ import { } from './ui-interaction-merge' export type UIUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts index 4a094ddb242..65b879795dc 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts @@ -1,10 +1,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' export type SshPtyBindingCleanupOperations = { - state: StoreOwnedPersistedState + state: PersistedState toComparablePtyId: (targetId: string, ptyId: string) => string scheduleSave: () => void } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index f7c8f07f24a..3b259989906 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -1,11 +1,11 @@ import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' export type SshPtyConsumerRecoveryOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick flushDurableStateOrThrowAsync: () => Promise } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index ef8fd642f24..2662f7f02da 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type SshPtyLeaseOperations = { - state: StoreOwnedPersistedState + state: PersistedState toStoredPtyId: (targetId: string, ptyId: string) => string clearBindingsForTarget: (targetId: string) => void clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index e0722e805c0..8dd5425b833 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -1,6 +1,6 @@ import type { ProjectHostSetup } from '../../../shared/project-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId @@ -13,7 +13,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetReassignmentOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void @@ -22,7 +22,7 @@ export type SshTargetReassignmentOperations = { /** Retirement namespaces key on the endpoint a target reaches, so a rotation moves them only when * the endpoint itself changed — plus any pre-identity key that embedded the row id. */ function migrateRetirementNamespaces( - state: StoreOwnedPersistedState, + state: PersistedState, oldTargetId: string, newTargetId: string ): boolean { diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index dafd76b1b6d..dbdb03b89a1 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -1,6 +1,5 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { @@ -15,7 +14,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetStateOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick scheduleSave: () => void flush: () => void diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts new file mode 100644 index 00000000000..fc62a4baf1e --- /dev/null +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -0,0 +1,158 @@ +import type { + Automation, + AutomationCreateInput, + AutomationDispatchResult, + AutomationRun, + AutomationRunTrigger, + AutomationUpdateInput +} from '../../../shared/automations-types' +import { getWorktreePathBasenameFromId } from '../../../shared/worktree/id' +import { normalizeAutomationRunWorkspaceDisplayName } from '../scheduling-automations/automation-context-migration' +import { + createAutomation as createAutomationOperation, + deleteAutomation as deleteAutomationOperation, + listAutomations as listAutomationsOperation, + updateAutomation as updateAutomationOperation, + type AutomationDefinitionOperations +} from '../scheduling-automations/automation-definition-operations' +import { + createAutomationRun as createAutomationRunOperation, + listAutomationRuns as listAutomationRunsOperation, + snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, + updateAutomationRun as updateAutomationRunOperation, + type AutomationRunOperations +} from '../scheduling-automations/automation-run-operations' +import { + advanceAutomationNextRun as advanceAutomationNextRunOperation, + getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation +} from '../scheduling-automations/automation-schedule-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { ProfilePreferences } from './profile-preferences' + +type AutomationPersistenceRuntime = Pick + +const automationPersistenceContext = Symbol('AutomationPersistence') +type AutomationPersistenceContext = { + runtime: AutomationPersistenceRuntime + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences +} + +export class AutomationPersistence { + readonly [automationPersistenceContext]: AutomationPersistenceContext + + constructor( + runtime: AutomationPersistenceRuntime, + flushBarriers: WriteFlushBarrierOperations, + preferences: ProfilePreferences + ) { + this[automationPersistenceContext] = { runtime, flushBarriers, preferences } + } + + listAutomations(): Automation[] { + return listAutomationsOperation(this[automationPersistenceContext].runtime.state) + } + + listAutomationRuns(automationId?: string): AutomationRun[] { + return listAutomationRunsOperation( + this[automationPersistenceContext].runtime.state, + automationId + ) + } + + createAutomation(input: AutomationCreateInput): Automation { + return createAutomationOperation(getAutomationDefinitionOperations(this), input) + } + + updateAutomation(id: string, updates: AutomationUpdateInput): Automation { + return updateAutomationOperation(getAutomationDefinitionOperations(this), id, updates) + } + + deleteAutomation(id: string): void { + deleteAutomationOperation(getAutomationDefinitionOperations(this), id) + } + + createAutomationRun( + automation: Automation, + scheduledFor: number, + trigger: AutomationRunTrigger = 'scheduled' + ): AutomationRun { + return createAutomationRunOperation( + getAutomationRunOperations(this), + automation, + scheduledFor, + trigger + ) + } + + updateAutomationRun(result: AutomationDispatchResult): AutomationRun { + return updateAutomationRunOperation(getAutomationRunOperations(this), result) + } + + snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { + return snapshotAutomationRunWorkspaceDisplayNameOperation( + getAutomationRunOperations(this), + workspaceId, + displayName + ) + } + + advanceAutomationNextRun(id: string, now = Date.now()): Automation { + return advanceAutomationNextRunOperation( + this[automationPersistenceContext].runtime.state, + () => this[automationPersistenceContext].flushBarriers.flush(), + id, + now + ) + } + + getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { + return getLatestAutomationOccurrenceOperation(automation, now) + } +} + +export function getAutomationDefinitionOperations( + owner: AutomationPersistence +): AutomationDefinitionOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordCreated: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') + } +} + +export function getAutomationRunOperations(owner: AutomationPersistence): AutomationRunOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordManualRun: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-run'), + getWorkspaceDisplayName: (workspaceId) => + getAutomationRunWorkspaceDisplayName(owner, workspaceId) + } +} + +export function getAutomationRunWorkspaceDisplayName( + owner: AutomationPersistence, + workspaceId: string | null | undefined +): string | null { + if (!workspaceId) { + return null + } + return normalizeAutomationRunWorkspaceDisplayName( + owner[automationPersistenceContext].runtime.state.worktreeMeta[workspaceId]?.displayName ?? + getWorktreePathBasenameFromId(workspaceId) + ) +} + +export function installAutomationPersistenceContext( + target: object, + source: AutomationPersistence +): void { + Object.defineProperty(target, automationPersistenceContext, { + value: source[automationPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts new file mode 100644 index 00000000000..7cf0a7a8078 --- /dev/null +++ b/src/main/persistence/loading-store/backup-recovery-rotation.ts @@ -0,0 +1,147 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { access, copyFile, rename, rm, stat } from 'node:fs/promises' +import { dirname } from 'node:path' + +const BACKUP_COUNT = 5 +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 + +function backupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false + ) +} + +export function hasStateBackup(dataFile: string): boolean { + for (let index = 0; index < BACKUP_COUNT; index += 1) { + if (existsSync(backupPath(dataFile, index))) { + return true + } + } + return false +} + +import type { StoreRuntimeState } from './store-runtime-state' + +type BackupRecoveryRotationOperationsRuntime = Pick + +export class BackupRecoveryRotationOperations { + constructor(private readonly runtime: BackupRecoveryRotationOperationsRuntime) {} + + shouldRotateBackups(now: number, dataFile: string): boolean { + try { + const mtime = statSync(backupPath(dataFile, 0)).mtimeMs + return now - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async shouldRotateBackupsAsync(dataFile: string): Promise { + try { + const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs + return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async rotateBackupsAsync(dataFile: string): Promise { + if (this.runtime.backupRotationInFlight) { + return + } + this.runtime.backupRotationInFlight = true + try { + if (!(await this.shouldRotateBackupsAsync(dataFile))) { + return + } + if (!(await exists(dataFile))) { + return + } + await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + }) + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an + // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. + if (await exists(src)) { + await rename(src, dst).catch((err) => { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + }) + } + } + await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + }) + } finally { + this.runtime.backupRotationInFlight = false + } + } + + rotateBackupsSync(dataFile: string): void { + if (!existsSync(dataFile)) { + return + } + try { + unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) + } catch (err) { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + } + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + if (existsSync(src)) { + try { + renameSync(src, dst) + } catch (err) { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + } + } + } + try { + copyFileSync(dataFile, backupPath(dataFile, 0)) + } catch (err) { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + } + } + + restoreFromBackup(dataFile: string): boolean { + for (let i = 0; i < BACKUP_COUNT; i++) { + const path = backupPath(dataFile, i) + if (!existsSync(path)) { + continue + } + try { + const raw = readFileSync(path, 'utf-8') + JSON.parse(raw) + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileSync(dataFile, raw, 'utf-8') + console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) + return true + } catch (err) { + console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) + } + } + return false + } +} diff --git a/src/main/persistence/loading-store/loaded-cohort-migrations.ts b/src/main/persistence/loading-store/loaded-cohort-migrations.ts new file mode 100644 index 00000000000..c4f21df6b46 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-cohort-migrations.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedCohortMigrationOperationsRuntime = Pick + +export class LoadedCohortMigrationOperations { + constructor(private readonly runtime: LoadedCohortMigrationOperationsRuntime) {} + + migrateTabSwitchKeybindings(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.tabSwitchKeybindingSeed + if (existing === 'pending' || existing === 'done') { + return state + } + // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. + this.runtime.loadNeedsSave = true + return { + ...state, + settings: { + ...state.settings, + // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. + tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' + } + } + } + + migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.telemetry + // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. + if ( + typeof existing?.existedBeforeTelemetryRelease === 'boolean' && + typeof existing.installId === 'string' && + existing.installId.length > 0 && + (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) + ) { + return state + } + // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. + const resolvedExistedBefore = + typeof existing?.existedBeforeTelemetryRelease === 'boolean' + ? existing.existedBeforeTelemetryRelease + : fileExistedOnLoad + return { + ...state, + settings: { + ...state.settings, + telemetry: { + ...existing, + existedBeforeTelemetryRelease: resolvedExistedBefore, + // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. + optedIn: + existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null + ? existing.optedIn + : resolvedExistedBefore + ? null + : true, + installId: + typeof existing?.installId === 'string' && existing.installId.length > 0 + ? existing.installId + : randomUUID() + } + } + } + } +} diff --git a/src/main/persistence/loading-store/loaded-state-adaptation.ts b/src/main/persistence/loading-store/loaded-state-adaptation.ts new file mode 100644 index 00000000000..fe350f6b851 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-adaptation.ts @@ -0,0 +1,116 @@ +import { isFolderRepo } from '../../../shared/repo-kind' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { createProjectGroup } from '../../../shared/project-groups' +import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedStateAdaptationOperationsRuntime = Pick + +export class LoadedStateAdaptationOperations { + constructor(private readonly runtime: LoadedStateAdaptationOperationsRuntime) {} + + hydrateFolderWorkspaceDiffComments(): void { + const stored = this.runtime.state.folderWorkspaceDiffComments + let relocatedInline = false + for (const workspace of this.runtime.state.folderWorkspaces ?? []) { + if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { + // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the + // older map untouched, so inline is the last notes-aware write. Also makes the relocation + // durable even if the user never edits anything this session. + relocatedInline = true + continue + } + const comments = stored?.[workspace.id] + // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. + if (Array.isArray(comments) && comments.length > 0) { + workspace.diffComments = comments + } + } + if (relocatedInline) { + this.runtime.loadNeedsSave = true + } + // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in + // state would make it a stale second source of truth that getDurableState() spreads back out. + delete this.runtime.state.folderWorkspaceDiffComments + } + + adaptFlatFolderScanProjectGroups(): boolean { + // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. + const groups = this.runtime.state.projectGroups ?? [] + const repos = this.runtime.state.repos + if (groups.length === 0 || repos.length === 0) { + return false + } + + let changed = false + let maxOrder = -1 + for (const group of groups) { + maxOrder = Math.max(maxOrder, group.tabOrder) + } + + const childGroupIds = new Set( + groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) + ) + const initialGroupCount = groups.length + for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { + const rootGroup = groups[groupIndex] + if (!rootGroup) { + continue + } + if ( + rootGroup.createdFrom !== 'folder-scan' || + !rootGroup.parentPath || + rootGroup.parentGroupId || + childGroupIds.has(rootGroup.id) + ) { + continue + } + const rootPath = rootGroup.parentPath + const repoCandidates = repos.filter( + (repo) => + !isFolderRepo(repo) && + repo.projectGroupId === rootGroup.id && + isPathInsideOrEqual(rootPath, repo.path) + ) + if (repoCandidates.length < 2) { + continue + } + + const resolver = createNestedProjectGroupResolver({ + parentPath: rootPath, + groupName: rootGroup.name, + mode: 'group', + repoPaths: repoCandidates.map((repo) => repo.path), + createGroup: (input) => { + if (!input.parentGroupId) { + return rootGroup + } + maxOrder += 1 + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + }) + groups.push(group) + changed = true + return group + } + }) + const nextOrderByGroupId = new Map() + for (const repo of repoCandidates) { + const group = resolver.getGroupForRepo(repo.path) + if (!group) { + continue + } + const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 + nextOrderByGroupId.set(group.id, nextOrder + 1) + if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { + repo.projectGroupId = group.id + repo.projectGroupOrder = nextOrder + changed = true + } + } + } + return changed + } +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts new file mode 100644 index 00000000000..2d29844ffb0 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -0,0 +1,267 @@ +import { readFileSync, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { normalizeProxyUrl } from '../../../shared/network-proxy' +import { normalizeKagiSessionLink } from '../../../shared/browser-url' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { clearMissingProjectGroupMemberships } from '../../../shared/project-groups' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + isStartupDiagnosticsEnabled, + logStartupDiagnostic +} from '../../startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot +} from '../../protected-secret-persistence' +import { + isLegacyOpenCodeSessionCookie, + isLegacySshPtyOwnerLease +} from '../leasing-ssh-ptys/secret-validation' +import { readGithubCacheSnapshot } from './user-data-path' +import { + gcStaleWorktreeMeta, + normalizeWorktreeLinkedItemMetadata +} from '../tracking-repos/worktree-metadata-normalization' +import { backfillLegacyAutomationContexts } from '../scheduling-automations/automation-context-migration' +import { + ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, + normalizeSshPtyConsumerRecovery +} from '../leasing-ssh-ptys/ssh-normalization' +import { + mergeProjectHostSetupCompatibilityState, + projectHostSetupCompatibilityStateEqual +} from '../tracking-repos/project-host-compatibility' +import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' +import { hasStateBackup } from './backup-recovery-rotation' +import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' + +function logPersistenceStartupMilestone( + event: string, + details: Record = {} +): void { + if (isStartupDiagnosticsEnabled()) { + logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import type { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' + +type LoadedStateParsingOperationsRuntime = Pick< + StoreRuntimeState, + | 'dataFile' + | 'githubCacheDirty' + | 'loadNeedsSave' + | 'protectedSecrets' + | 'terminalScrollbackSnapshotStorage' +> + +export class LoadedStateParsingOperations { + constructor( + private readonly runtime: LoadedStateParsingOperationsRuntime, + private readonly backups: BackupRecoveryRotationOperations, + private readonly cohorts: LoadedCohortMigrationOperations + ) {} + + load(allowBackupRecovery = true): PersistedState { + // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. + const dataFile = this.runtime.dataFile + const fileExistedOnLoad = existsSync(dataFile) + logPersistenceStartupMilestone('persistence-load-start', { + fileExists: fileExistedOnLoad + }) + + let result: PersistedState | null = null + try { + if (fileExistedOnLoad) { + const readStartedAt = performance.now() + const raw = readFileSync(dataFile, 'utf-8') + logPersistenceStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logPersistenceStartupMilestone('persistence-json-parse-start') + const parsed = JSON.parse(raw) as PersistedState + logPersistenceStartupMilestone('persistence-json-parse-done') + + // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. + if (parsed.settings?.opencodeSessionCookie) { + parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) + } + if (parsed.settings?.httpProxyUrl) { + const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.runtime.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + this.runtime.loadNeedsSave = true + } + } + if (parsed.ui?.browserKagiSessionLink) { + parsed.ui.browserKagiSessionLink = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) + } + parsed.sshPtyConsumerRecoveries = ( + Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] + ) + .map((record) => + normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) + ) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.runtime.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.runtime.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + + const terminalSettings = prepareLoadedTerminalSettings(parsed, () => { + this.runtime.loadNeedsSave = true + }) + const profileSettings = prepareLoadedProfileSettings( + parsed, + terminalSettings.defaults, + () => { + this.runtime.loadNeedsSave = true + } + ) + result = normalizeLoadedProfileState(parsed, terminalSettings, profileSettings, () => { + this.runtime.loadNeedsSave = true + }) + } + } catch (err) { + console.error('[persistence] Failed to load primary state, trying backups:', err) + } + + // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. + if (result === null && allowBackupRecovery) { + const hasBackup = hasStateBackup(dataFile) + if (fileExistedOnLoad || hasBackup) { + if (this.backups.restoreFromBackup(dataFile)) { + return this.load(false) + } + console.error('[persistence] No usable state file or backup found, using defaults') + } + } + + if (result === null) { + result = getDefaultPersistedState(homedir()) + } + + const workspaceSession = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) + ) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + workspaceSession, + this.runtime.terminalScrollbackSnapshotStorage + ) + if (migratedScrollback.changed) { + this.runtime.loadNeedsSave = true + } + + const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) + if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { + this.runtime.loadNeedsSave = true + } + + const automationContextMigration = backfillLegacyAutomationContexts({ + ...result, + repos, + ...projectHostSetupCompatibility + }) + if (automationContextMigration.changed) { + this.runtime.loadNeedsSave = true + } + result = { + ...result, + automations: automationContextMigration.state.automations, + automationRuns: automationContextMigration.state.automationRuns + } + + const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ + ...result, + repos, + ...projectHostSetupCompatibility, + workspaceSession: migratedScrollback.session + }) + if (folderScopeConnectionMigration.changed) { + this.runtime.loadNeedsSave = true + } + result = folderScopeConnectionMigration.state + + if (normalizeWorktreeLinkedItemMetadata(result)) { + this.runtime.loadNeedsSave = true + } + + if (gcStaleWorktreeMeta(result) > 0) { + this.runtime.loadNeedsSave = true + } + + const migrated = this.cohorts.migrateTabSwitchKeybindings( + this.cohorts.migrateTelemetry(result, fileExistedOnLoad), + fileExistedOnLoad + ) + + // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.runtime.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. + this.runtime.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache + } + + logPersistenceStartupMilestone('persistence-load-done', { + repos: migrated.repos.length, + workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) + }) + return migrated + } +} diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts new file mode 100644 index 00000000000..056991dd1f4 --- /dev/null +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -0,0 +1,254 @@ +import { randomUUID } from 'node:crypto' +import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' +import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + workspaceSessionOwnerPartitionForHost, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' +import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' + +function getDefaultWorktreeMeta(): WorktreeMeta { + return { + instanceId: randomUUID(), + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: Date.now(), + lastActivityAt: 0, + workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' +import { + hasPersistedWorkspaceSession, + partitionOwnsWorktreeTabs, + partitionHasOtherRepoWorktreeTabs, + removeWorkspaceSessionOwnerInPartition +} from './session-host-partitions' + +type MetadataLineageOperationsRuntime = Pick + +const metadataLineageOperationsContext = Symbol('MetadataLineageOperations') +type MetadataLineageOperationsContext = { + runtime: MetadataLineageOperationsRuntime + scheduling: WriteSchedulingOperations + sessions: SessionHostPartitionOperations +} + +export class MetadataLineageOperations { + readonly [metadataLineageOperationsContext]: MetadataLineageOperationsContext + + constructor( + runtime: MetadataLineageOperationsRuntime, + scheduling: WriteSchedulingOperations, + sessions: SessionHostPartitionOperations + ) { + this[metadataLineageOperationsContext] = { runtime, scheduling, sessions } + } + + getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + } + + getAllWorktreeMeta(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta + } + + setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { + const existing = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] || + getDefaultWorktreeMeta() + const updated = { ...existing, ...meta } + updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) + const linkedTaskSourceContext = normalizeStoredTaskSourceContext( + updated.linkedTaskSourceContext + ) + updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + updated.linkedWorkItem, + linkedTaskSourceContext + ) + ? linkedTaskSourceContext + : null + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] = updated + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return updated + } + + removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { + // A host-qualified removal names the owner; the persisted host is the fallback. + const persistedOwner = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]?.hostId + const owner = hostId ?? persistedOwner + const preservesDifferentPersistedOwner = Boolean( + hostId && persistedOwner && persistedOwner !== hostId + ) + const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) + const preservesSameIdSessionOwner = Boolean( + preservesDifferentPersistedOwner || + (owner && + hasWorktreeRemovalRepoOwnerOnOtherHost( + { getRepos: () => this[metadataLineageOperationsContext].runtime.state.repos }, + getRepoIdFromWorktreeId(worktreeId), + ownerPartition + )) + ) + // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. + const partitions = new Set( + workspaceSessionPartitionIdsForHost(owner).filter( + (partition) => + hasPersistedWorkspaceSession( + this[metadataLineageOperationsContext].sessions, + partition + ) && + // The local partition can be a remote spill surface or a same-id owner. + // Preserve it whenever another owner may still use the bare id. + (!preservesSameIdSessionOwner || partition === ownerPartition) + ) + ) + // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill + // partition that never held this worktree has no claim on the repo at all. + const fencedPartitions = new Set( + [...partitions].filter( + (partition) => + partitionOwnsWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + ) || + (partition === ownerPartition && + !partitionHasOtherRepoWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + )) + ) + ) + if (!preservesDifferentPersistedOwner) { + delete this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + worktreeWorkspaceKey(worktreeId) + ] + } + for (const partition of partitions) { + removeWorkspaceSessionOwnerInPartition( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition, + { + advanceTerminalTopologyRevision: fencedPartitions.has(partition) + } + ) + } + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById + } + + setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { + this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorktreeLineage(worktreeId: string): void { + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { + if ( + migrateWorktreeIdentityOperation( + this[metadataLineageOperationsContext].runtime.state, + oldWorktreeId, + newWorktreeId + ) + ) { + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + } + + getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + } + + getAllWorkspaceLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + } + + setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { + this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + lineage.childWorkspaceKey + ] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } +} + +export function removeWorkspaceLineageForFolderParent( + owner: MetadataLineageOperations, + folderWorkspaceId: string +): void { + const parentKey = folderWorkspaceKey(folderWorkspaceId) + for (const [childKey, lineage] of Object.entries( + owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + )) { + if (lineage.parentWorkspaceKey === parentKey) { + delete owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childKey as WorkspaceKey + ] + } + } +} + +export function installMetadataLineageOperationsContext( + target: object, + source: MetadataLineageOperations +): void { + Object.defineProperty(target, metadataLineageOperationsContext, { + value: source[metadataLineageOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts new file mode 100644 index 00000000000..321baad1aaf --- /dev/null +++ b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts @@ -0,0 +1,42 @@ +import type { PersistedMobileClientTabSelections } from '../../../shared/persisted-state-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type MobileTabSelectionRuntime = Pick + +const mobileTabSelectionPersistenceContext = Symbol('MobileTabSelectionPersistence') +type MobileTabSelectionPersistenceContext = { + runtime: MobileTabSelectionRuntime + scheduling: WriteSchedulingOperations +} + +export class MobileTabSelectionPersistence { + readonly [mobileTabSelectionPersistenceContext]: MobileTabSelectionPersistenceContext + + constructor(runtime: MobileTabSelectionRuntime, scheduling: WriteSchedulingOperations) { + this[mobileTabSelectionPersistenceContext] = { runtime, scheduling } + } + + getMobileClientTabSelections(): PersistedMobileClientTabSelections { + return ( + this[mobileTabSelectionPersistenceContext].runtime.state + .mobileClientTabSelectionsByDeviceId ?? {} + ) + } + + setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { + this[mobileTabSelectionPersistenceContext].runtime.state.mobileClientTabSelectionsByDeviceId = + next + scheduleSave(this[mobileTabSelectionPersistenceContext].scheduling) + } +} + +export function installMobileTabSelectionPersistenceContext( + target: object, + source: MobileTabSelectionPersistence +): void { + Object.defineProperty(target, mobileTabSelectionPersistenceContext, { + value: source[mobileTabSelectionPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts new file mode 100644 index 00000000000..fb5cc9f1fe8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -0,0 +1,138 @@ +import { getDefaultVoiceSettings } from '../../../shared/constants' +import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' +import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' +import { normalizeOpenInApplications } from '../../../shared/open-in-applications' +import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' +import { normalizeAppIconId } from '../../../shared/app-icon' +import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' +import { normalizeUiLanguage } from '../../../shared/ui-language' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { readLegacySidekickFlag } from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' + +export function normalizeLoadedGlobalSettings( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings +): PersistedState['settings'] { + const { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds + } = terminal + const { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder + } = profile + + return { + ...defaults.settings, + // Why (#7977): v1.4.130 onboarding persisted this as a plain boolean, making the + // old default indistinguishable from a real opt-in. Preserve stored `true`; only + // the default changed. + ...stripRetiredGlobalSettings(parsed.settings), + worktreeVisibilityDefaults: migratedExternalVisibility.defaults, + prBotAuthorOverrides: normalizePRBotAuthorOverrides(parsed.settings?.prBotAuthorOverrides), + // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. + experimentalPet: parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, + // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. + primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault + ? true + : (parsed.settings?.primarySelectionMiddleClickPaste ?? + defaults.settings.primarySelectionMiddleClickPaste), + primarySelectionMiddleClickPasteDefaultedForLinux: + primarySelectionDefaultedForLinux || + (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: + primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, + ...migratedAutoRenameBranchFromWork, + ...migratedTerminalCursorStyle, + terminalLineHeight: migratedTerminalLineHeight, + // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. + terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform + ? (parsed.settings?.terminalRightClickToPaste ?? defaults.settings.terminalRightClickToPaste) + : parsed.settings?.terminalRightClickToPaste === false + ? false + : defaults.settings.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, + ...migratedTerminalTuiScrollSensitivity.settings, + experimentalActivity: migratedExperimentalActivity, + experimentalActivityDefaultedOffForAllUsers: true, + // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. + compactWorktreeCards: loadedCompactWorktreeCards, + experimentalCompactWorktreeCards: undefined, + terminalMacOptionAsAlt: migratedOptionAsAlt, + terminalMacOptionAsAltMigrated: true, + localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, + localAccountRuntime: migratedLocalAccountRuntime, + localAccountRuntimeDefaultedToAutoForAllUsers: true, + ...migratedOsc52Clipboard, + floatingTerminalEnabled: migratedFloatingTerminalEnabled, + floatingTerminalDefaultedForAllUsers: true, + floatingTerminalCwd: migratedFloatingTerminalCwd, + floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, + floatingTerminalCwdMigratedToAppWorkspace: true, + terminalScrollbackRows: migratedTerminalScrollback.rows, + terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), + terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), + appIcon: normalizeAppIconId(parsed.settings?.appIcon), + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. + minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, + uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), + defaultTaskSource: taskProviderSettings.defaultTaskSource, + visibleTaskProviders: taskProviderSettings.visibleTaskProviders, + visibleTaskProvidersDefaultedForJira: true, + terminalShortcutPolicy: normalizeTerminalShortcutPolicy( + parsed.settings?.terminalShortcutPolicy + ), + disabledTuiAgents: migratedDisabledTuiAgents, + ...migratedAgentYoloDefaults, + claudeAgentTeamsDefaultDisabledMigrated: true, + openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { + seedDefaults: true + }), + notifications: normalizedNotifications, + sourceControlAi: migratedSourceControlAi, + sourceControlGroupOrder: normalizedSourceControlGroupOrder, + // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. + commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( + migratedSourceControlAi, + parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi + ), + voice: { + ...getDefaultVoiceSettings(), + ...parsed.settings?.voice + } + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts new file mode 100644 index 00000000000..464a1eaa9b8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -0,0 +1,91 @@ +import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { normalizeFeatureInteractionTelemetryBuckets } from '../../../shared/feature-interactions' +import { normalizeFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' +import { normalizeWorkspaceLineageByChildKey } from '../applying-settings/ui-interaction-merge' +import { + normalizeSshRemotePtyLease, + normalizeSshTarget +} from '../leasing-ssh-ptys/ssh-normalization' +import { + normalizeClaudeLivePtySessionIds, + normalizeLegacyPaneKeyAliasEntries, + normalizeMigrationUnsupportedPtyEntries +} from '../restoring-sessions/pane-alias-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings' +import { normalizeLoadedUiState } from './normalize-loaded-ui-state' +import { + normalizeLoadedAutomationRuns, + normalizeLoadedHostSessions, + normalizeLoadedLocalSession +} from './normalize-loaded-state-collections' +import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' + +export function normalizeLoadedProfileState( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings, + markNeedsSave: () => void +): PersistedState { + const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal + const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile + + return { + ...defaults, + ...parsed, + featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( + parsed.featureInteractionTelemetryBuckets + ), + projectGroups: normalizedProjectGroups, + repos: migratedExternalVisibility.repos, + folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups), + folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( + parsed.folderWorkspaceDiffComments + ), + worktreeLineageById: parsed.worktreeLineageById ?? {}, + mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( + parsed.mobileClientTabSelectionsByDeviceId + ), + workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( + parsed.workspaceLineageByChildKey + ), + settings: normalizeLoadedGlobalSettings(parsed, terminal, profile), + // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. + ui: normalizeLoadedUiState( + parsed, + defaults, + normalizedOnboarding, + loadedCompactWorktreeCards, + osc52ClipboardNoticePending, + markNeedsSave + ), + // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. + workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave), + // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. + workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave), + sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string') + : [], + retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap(parsed.retiredWorktreeNamesByRepo), + retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( + parsed.retiredWorktreeNamesByNamespace + ), + sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) + .map(normalizeSshRemotePtyLease) + .filter((lease): lease is SshRemotePtyLease => lease !== null), + sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, + claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), + migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( + parsed.migrationUnsupportedPtyEntries + ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries(parsed.legacyPaneKeyAliasEntries), + automations: Array.isArray(parsed.automations) ? parsed.automations : [], + automationRuns: normalizeLoadedAutomationRuns(parsed, markNeedsSave), + onboarding: normalizedOnboarding + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts new file mode 100644 index 00000000000..978515a5752 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -0,0 +1,66 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' +import { + backfillAutomationRunNumbers, + pruneAutomationRuns +} from '../../../shared/automation-run-retention' +import { + parseWorkspaceSessionsByHostId, + workspaceSessionSalvageLogDetails +} from './workspace-session-partitions' + +export function normalizeLoadedLocalSession( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): WorkspaceSessionState { + if (parsed.workspaceSession === undefined) { + return defaults.workspaceSession + } + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) + if (!result.ok) { + console.error('[persistence] Corrupt workspace session, using defaults:', result.error) + return defaults.workspaceSession + } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return { ...defaults.workspaceSession, ...result.value } +} + +export function normalizeLoadedHostSessions( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PersistedState['workspaceSessionsByHostId'] { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return partitions +} + +export function normalizeLoadedAutomationRuns( + parsed: PersistedState, + markNeedsSave: () => void +): PersistedState['automationRuns'] { + if (!Array.isArray(parsed.automationRuns)) { + return [] + } + const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) + // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. + if (runs.length !== parsed.automationRuns.length) { + markNeedsSave() + } + return runs +} diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts new file mode 100644 index 00000000000..d26f4e68430 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -0,0 +1,206 @@ +import { + getWorktreeCardModeProperties, + isDefaultedCompactWorktreeCardProperties, + normalizeWorktreeCardProperties +} from '../../../shared/constants' +import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' +import { normalizePersistedWorkspaceStatuses } from '../../../shared/workspace-statuses' +import { + normalizeRightSidebarExplorerView, + normalizeRightSidebarTab, + normalizeShowDotfilesByWorktree, + normalizeSortBy +} from '../applying-settings/ui-selection-normalization' +import { stripMainOwnedTelemetryMarkerFromUI } from '../applying-settings/ui-interaction-merge' +import { + readDeprecatedExperimentFlag, + resolveSetupGuideSidebarDismissedOnLoad +} from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { OnboardingState } from '../../../shared/onboarding-state-types' + +export function normalizeLoadedUiState( + parsed: PersistedState, + defaults: PersistedState, + normalizedOnboarding: OnboardingState, + loadedCompactWorktreeCards: boolean, + osc52ClipboardNoticePending: boolean, + markNeedsSave: () => void +): PersistedState['ui'] { + const rawSort = parsed.ui?.sortBy + const sort = normalizeSortBy(rawSort) + const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' + const rightSidebarOpen = + typeof parsed.ui?.rightSidebarOpen === 'boolean' + ? parsed.ui.rightSidebarOpen + : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' + ? parsed.settings.rightSidebarOpenByDefault + : defaults.ui.rightSidebarOpen + if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { + markNeedsSave() + } + const workspaceStatusesDefaultOrderMigrated = + parsed.ui?._workspaceStatusesDefaultOrderMigrated === true + // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. + const workspaceStatusesReorderedDefaultRepaired = + parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true + // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. + const workspaceStatusesDefaultWorkflowMigrated = + parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true + // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. + const workspaceStatusesDefaultVisualsMigrated = + parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true + const workspaceStatuses = normalizePersistedWorkspaceStatuses(parsed.ui?.workspaceStatuses, { + migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, + repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, + migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated + }) + if ( + !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesReorderedDefaultRepaired || + !workspaceStatusesDefaultWorkflowMigrated || + !workspaceStatusesDefaultVisualsMigrated + ) { + markNeedsSave() + } + const rawCardProps = parsed.ui?.worktreeCardProperties + const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true + const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true + const hadExperimentOn = readDeprecatedExperimentFlag(parsed) + const deliberateUncheck = + hadExperimentOn && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') + const needsInlineAgentsMigration = + !inlineAgentsMigrated && + !deliberateUncheck && + Array.isArray(rawCardProps) && + !rawCardProps.includes('inline-agents') + const needsLegacyDefaultedCompactMigration = + loadedCompactWorktreeCards && + parsed.ui?._worktreeCardModeDefaulted === true && + isDefaultedCompactWorktreeCardProperties(rawCardProps) + const migratedCardProps = (() => { + if (!Array.isArray(rawCardProps)) { + return undefined + } + if (needsLegacyDefaultedCompactMigration) { + return getWorktreeCardModeProperties('Compact') + } + const candidate = needsInlineAgentsMigration + ? [...rawCardProps, 'inline-agents' as const] + : rawCardProps + const expandedCandidate = (() => { + if (expandedCardPropsMigrated) { + return candidate + } + const next = [...candidate] + // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. + if (candidate.includes('issue') && !candidate.includes('linear-issue')) { + next.push('linear-issue' as const) + } + if (!candidate.includes('ports')) { + next.push('ports' as const) + } + return next + })() + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) + const changed = + normalized.length !== rawCardProps.length || + normalized.some((property, index) => property !== rawCardProps[index]) + return changed ? normalized : undefined + })() + if ( + migratedCardProps !== undefined || + !inlineAgentsMigrated || + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted + ) { + markNeedsSave() + } + const rawExplorerView = parsed.ui?.rightSidebarExplorerView + const rightSidebarExplorerView = normalizeRightSidebarExplorerView( + rawExplorerView, + parsed.ui?.rightSidebarTab + ) + // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays + // in memory only, so a profile that never writes again redoes it on every launch. + if ( + rawExplorerView === undefined + ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView + : rawExplorerView !== rightSidebarExplorerView + ) { + markNeedsSave() + } + const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( + parsed.ui?.setupGuideSidebarDismissed, + normalizedOnboarding + ) + if ( + parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && + (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) + ) { + markNeedsSave() + } + // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + markNeedsSave() + } + return { + ...defaults.ui, + // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. + worktreeCardProperties: getWorktreeCardModeProperties( + loadedCompactWorktreeCards ? 'Compact' : 'Default' + ), + ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), + // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. + rightSidebarOpen, + rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), + // Why here and not in getPersistedUI: only the raw payload still shows the legacy + // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. + rightSidebarExplorerView, + setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, + setupGuideBrowserMilestoneMigrated: + typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' + ? parsed.ui.setupGuideBrowserMilestoneMigrated + : false, + setupGuideBrowserMilestoneLegacyComplete: + parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, + // Why persist rather than notify inline: the flip lands during load, before any + // window exists, and it must survive a crash before the user ever sees the notice. + osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, + sortBy: migrate ? ('smart' as const) : sort, + showDotfilesByWorktree: normalizeShowDotfilesByWorktree(parsed.ui?.showDotfilesByWorktree), + workspaceStatuses, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true, + _sortBySmartMigrated: true, + ...(migratedCardProps !== undefined ? { worktreeCardProperties: migratedCardProps } : {}), + // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. + _inlineAgentsDefaultedForExperiment: true, + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts new file mode 100644 index 00000000000..2410e68be54 --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts @@ -0,0 +1,255 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' +import { normalizeTaskProviderSettings } from '../../../shared/task-providers' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../shared/mobile-pairing-custom-address' +import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' +import { normalizeProjectGroups } from '../../../shared/project-groups' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' +import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' +import { migrateAgentYoloDefaults } from '../applying-settings/terminal-settings-migrations' +import { + normalizeLoadedOnboardingState, + normalizeNotificationSettings, + persistedNotificationSettingsRepaired +} from '../applying-settings/onboarding-normalization' + +export type PreparedLoadedProfileSettings = { + migratedExperimentalActivity: GlobalSettings['experimentalActivity'] + migratedAutoRenameBranchFromWork: Pick< + GlobalSettings, + 'autoRenameBranchFromWork' | 'autoRenameBranchFromWorkDefaultedOn' + > + migratedTerminalCursorStyle: Pick< + GlobalSettings, + 'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock' + > + migratedTerminalLineHeight: GlobalSettings['terminalLineHeight'] + terminalRightClickToPasteDefaultedForPlatform: boolean + taskProviderSettings: Pick + primarySelectionDefaultedForLinux: boolean + primarySelectionDefaultedForTerminalDefaults: boolean + migratePrimarySelectionPlatformDefault: boolean + stampPrimarySelectionTerminalDefaults: boolean + migratedDisabledTuiAgents: GlobalSettings['disabledTuiAgents'] + migratedAgentYoloDefaults: Pick< + GlobalSettings, + 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated' + > + migratedWindowsRuntimeDefault: GlobalSettings['localWindowsRuntimeDefault'] + migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] + loadedCompactWorktreeCards: boolean + mobilePairingCustomAddress: GlobalSettings['mobilePairingCustomAddress'] + mobilePairingCustomAddresses: GlobalSettings['mobilePairingCustomAddresses'] + normalizedNotifications: GlobalSettings['notifications'] + normalizedSourceControlGroupOrder: GlobalSettings['sourceControlGroupOrder'] + normalizedOnboarding: PersistedState['onboarding'] + normalizedProjectGroups: ProjectGroup[] +} + +export function prepareLoadedProfileSettings( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PreparedLoadedProfileSettings { + const experimentalActivityDefaultedOffForAllUsers = + parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true + // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. + const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers + ? (parsed.settings?.experimentalActivity ?? false) + : false + const autoRenameBranchFromWorkDefaultedOn = + parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true + // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. + const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( + parsed.settings + ) + const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + markNeedsSave() + } + const migratedTerminalLineHeight = normalizeTerminalLineHeight( + parsed.settings?.terminalLineHeight + ) + const terminalRightClickToPasteDefaultedForPlatform = + parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true + if (!terminalRightClickToPasteDefaultedForPlatform) { + markNeedsSave() + } + if ( + parsed.settings?.terminalLineHeight !== undefined && + parsed.settings.terminalLineHeight !== migratedTerminalLineHeight + ) { + markNeedsSave() + } + const rawTaskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: parsed.settings?.visibleTaskProviders, + defaultTaskSource: parsed.settings?.defaultTaskSource + }) + const visibleTaskProvidersDefaultedForJira = + parsed.settings?.visibleTaskProvidersDefaultedForJira === true + const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira + ? rawTaskProviderSettings.visibleTaskProviders + : rawTaskProviderSettings.visibleTaskProviders.includes('jira') + ? rawTaskProviderSettings.visibleTaskProviders + : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: migratedVisibleTaskProviders, + defaultTaskSource: rawTaskProviderSettings.defaultTaskSource + }) + const primarySelectionDefaultedForLinux = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true + const primarySelectionDefaultedForTerminalDefaults = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true + const primarySelectionPlatformDefaultEnabled = + defaults.settings.primarySelectionMiddleClickPaste === true + const primarySelectionAlreadyDefaultedForPlatform = + primarySelectionDefaultedForTerminalDefaults || + (process.platform === 'linux' && primarySelectionDefaultedForLinux) + const migratePrimarySelectionPlatformDefault = + primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform + const stampPrimarySelectionTerminalDefaults = + primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults + if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { + markNeedsSave() + } + if (!visibleTaskProvidersDefaultedForJira) { + markNeedsSave() + } + const claudeAgentTeamsDefaultDisabledMigrated = + parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true + if (!claudeAgentTeamsDefaultDisabledMigrated) { + markNeedsSave() + } + const migratedDisabledTuiAgents = normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents) + const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) + if ( + parsed.settings?.agentYoloDefaultsMigrated !== true || + hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || + hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) + ) { + markNeedsSave() + } + if ( + !claudeAgentTeamsDefaultDisabledMigrated && + !migratedDisabledTuiAgents.includes('claude-agent-teams') + ) { + migratedDisabledTuiAgents.push('claude-agent-teams') + } + const migratedWindowsRuntimeDefault = + parsed.settings?.localWindowsRuntimeDefault === undefined + ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime + : parsed.settings.localWindowsRuntimeDefault + if ( + parsed.settings?.localWindowsRuntimeDefault === undefined && + migratedWindowsRuntimeDefault.kind === 'wsl' + ) { + markNeedsSave() + } + // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. + const localAccountRuntimeAlreadyMigrated = + parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true + const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = + localAccountRuntimeAlreadyMigrated + ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) + : parsed.settings?.localAccountRuntime === 'wsl' + ? 'wsl' + : 'auto' + if (!localAccountRuntimeAlreadyMigrated) { + markNeedsSave() + } + if (!autoRenameBranchFromWorkDefaultedOn) { + markNeedsSave() + } + const normalizedOnboarding = normalizeLoadedOnboardingState( + parsed.onboarding, + defaults.onboarding + ) + if (!parsed.onboarding) { + markNeedsSave() + } + const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) + const loadedCompactWorktreeCards = + parsed.settings?.compactWorktreeCards ?? + parsed.settings?.experimentalCompactWorktreeCards ?? + defaults.settings.compactWorktreeCards + const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + parsed.settings?.mobilePairingCustomAddress + ) + const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses + const mobilePairingCustomAddresses = mobilePairingCustomAddress + ? addMobilePairingCustomAddress( + normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), + mobilePairingCustomAddress + ) + : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) + if ( + parsed.settings?.mobilePairingCustomAddress !== undefined && + parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress + ) { + markNeedsSave() + } + const customAddressesMatch = + Array.isArray(rawMobilePairingCustomAddresses) && + rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && + rawMobilePairingCustomAddresses.every( + (address, index) => address === mobilePairingCustomAddresses[index] + ) + if ( + (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && + !customAddressesMatch + ) { + markNeedsSave() + } + const normalizedNotifications = normalizeNotificationSettings(parsed.settings?.notifications) + // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the + // bad value stays on disk and the repair reruns on every launch. + if ( + persistedNotificationSettingsRepaired(parsed.settings?.notifications, normalizedNotifications) + ) { + markNeedsSave() + } + const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( + parsed.settings?.sourceControlGroupOrder + ) + if ( + parsed.settings?.sourceControlGroupOrder !== undefined && + parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder + ) { + markNeedsSave() + } + return { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder, + normalizedOnboarding, + normalizedProjectGroups + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts new file mode 100644 index 00000000000..65bf099891d --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts @@ -0,0 +1,172 @@ +import { homedir } from 'node:os' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + GlobalSettings, + WorktreeVisibilityDefaults +} from '../../../shared/global-settings-types' +import type { Repo } from '../../../shared/repo-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' +import { + mergeLegacyCommitMessageAiIntoSourceControlAi, + sourceControlAiSettingsFromLegacy +} from '../../../shared/source-control-ai' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../shared/osc52-clipboard-settings' +import { + migrateTerminalScrollbackRows, + migrateTerminalTuiScrollSensitivityDefault +} from '../applying-settings/terminal-settings-migrations' +import { + canonicalizePersistedFloatingWorkspaceDirectory, + normalizeFloatingWorkspaceTrustedCwds +} from '../restoring-sessions/floating-workspace-normalization' + +export type PreparedLoadedTerminalSettings = { + defaults: PersistedState + migratedExternalVisibility: { + repos: Repo[] + defaults: WorktreeVisibilityDefaults + changed: boolean + } + migratedTerminalScrollback: { rows: number; needsSave: boolean } + migratedTerminalTuiScrollSensitivity: { + settings: Pick< + GlobalSettings, + 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' + > + needsSave: boolean + } + migratedSourceControlAi: NonNullable + migratedOptionAsAlt: GlobalSettings['terminalMacOptionAsAlt'] + migratedFloatingTerminalEnabled: GlobalSettings['floatingTerminalEnabled'] + migratedOsc52Clipboard: Pick< + GlobalSettings, + 'terminalAllowOsc52Clipboard' | 'terminalAllowOsc52ClipboardDefaultedOnForAllUsers' + > + migratedFloatingTerminalCwd: GlobalSettings['floatingTerminalCwd'] + migratedFloatingTerminalTrustedCwds: GlobalSettings['floatingTerminalTrustedCwds'] + osc52ClipboardNoticePending: boolean +} + +export function prepareLoadedTerminalSettings( + parsed: PersistedState, + markNeedsSave: () => void +): PreparedLoadedTerminalSettings { + // Merge with defaults in case new fields were added + const homeDir = homedir() + const defaults = getDefaultPersistedState(homeDir) + const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( + Array.isArray(parsed.repos) ? parsed.repos : [], + parsed.settings?.worktreeVisibilityDefaults + ) + if (migratedExternalVisibility.changed) { + markNeedsSave() + } + const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) + if (migratedTerminalScrollback.needsSave) { + markNeedsSave() + } + if ( + parsed.settings && + typeof parsed.settings === 'object' && + Object.hasOwn(parsed.settings, 'enableGitHubAttribution') + ) { + markNeedsSave() + } + const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( + parsed.settings + ) + if (migratedTerminalTuiScrollSensitivity.needsSave) { + markNeedsSave() + } + const rawSourceControlAi = parsed.settings?.sourceControlAi + const rawSourceControlAiMissing = rawSourceControlAi === undefined + const rawSourceControlAiActionsMissing = + rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined + if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { + markNeedsSave() + } + const legacyCommitMessageAi = parsed.settings?.commitMessageAi + const migratedSourceControlAi = rawSourceControlAiMissing + ? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi) + : mergeLegacyCommitMessageAiIntoSourceControlAi( + parsed.settings?.sourceControlAi, + legacyCommitMessageAi + ) + // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. + const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt + const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true + const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated + ? (rawOptionAsAlt ?? 'auto') + : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' + ? 'auto' + : rawOptionAsAlt + const floatingTerminalDefaultedForAllUsers = + parsed.settings?.floatingTerminalDefaultedForAllUsers === true + // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. + const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers + ? (parsed.settings?.floatingTerminalEnabled ?? true) + : true + // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). + const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) + const osc52ClipboardNoticePending = + osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || + parsed.ui?.osc52ClipboardDefaultOnNoticePending === true + if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { + markNeedsSave() + } + const floatingTerminalCwdMigrated = + parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true + // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. + const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated + ? !parsed.settings?.floatingTerminalCwd + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + : parsed.settings?.floatingTerminalCwd === undefined + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( + parsed.settings?.floatingTerminalTrustedCwds, + homeDir + ) + const migratedFloatingTerminalTrustedCwds = [...normalizedFloatingTerminalTrustedCwds.trustedCwds] + const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd + const shouldTrustLegacyFloatingTerminalCwd = + !floatingTerminalCwdMigrated && + typeof rawLegacyFloatingTerminalCwd === 'string' && + rawLegacyFloatingTerminalCwd.trim().length > 0 && + rawLegacyFloatingTerminalCwd.trim() !== '~' + if (!floatingTerminalCwdMigrated) { + markNeedsSave() + } + if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { + const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( + rawLegacyFloatingTerminalCwd, + homeDir + ) + if (canonicalLegacyCwd && !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd)) { + // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. + migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) + normalizedFloatingTerminalTrustedCwds.changed = true + } + } + if (normalizedFloatingTerminalTrustedCwds.changed) { + markNeedsSave() + } + return { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds, + osc52ClipboardNoticePending + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts new file mode 100644 index 00000000000..9d6e476ebe5 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -0,0 +1,282 @@ +import { mkdirSync, existsSync, unlinkSync } from 'node:fs' +import { mkdir, open, rm } from 'node:fs/promises' +import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' +import { dirname } from 'node:path' +import { + parseCodexResetCreditAttemptLedger, + type CodexResetCreditAttemptLedger +} from '../../../shared/codex-reset-credit-attempt-ledger' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' + +type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'backupRotationInFlight' + | 'dataFile' + | 'flushOrThrow' + | 'firstPendingSaveAt' + | 'inFlightAsyncTmpFile' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingWrite' + | 'protectedSecrets' + | 'quitFlushStarted' + | 'staleTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') +type PrimaryStateWriteOperationsContext = { + runtime: PrimaryStateWriteOperationsRuntime + serialization: StateSerializationSecretHandlingOperations + backups: BackupRecoveryRotationOperations +} + +export class PrimaryStateWriteOperations { + readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext + + constructor( + runtime: PrimaryStateWriteOperationsRuntime, + serialization: StateSerializationSecretHandlingOperations, + backups: BackupRecoveryRotationOperations + ) { + this[primaryStateWriteOperationsContext] = { runtime, serialization, backups } + } + + flushOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + throw new Error('Cannot synchronously flush after final persistence has started') + } + if (this[primaryStateWriteOperationsContext].runtime.writeTimer) { + clearTimeout(this[primaryStateWriteOperationsContext].runtime.writeTimer) + this[primaryStateWriteOperationsContext].runtime.writeTimer = null + } + this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null + const asyncWriteWasInFlight = + this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null + // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. + this[primaryStateWriteOperationsContext].runtime.writeGeneration++ + if (this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) { + try { + unlinkSync(this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) + this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void enqueueWrite(this).catch(() => {}) + throw error + } + } + } + // Why: later async flushes must remain serialized behind the invalidated writer. + writeToDiskSync(this, { + force: asyncWriteWasInFlight, + skipBackupRotation: this[primaryStateWriteOperationsContext].runtime.backupRotationInFlight + }) + } + + flushActiveViewPreferenceOrThrow(): void { + this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() + } + + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { + return parseCodexResetCreditAttemptLedger( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + } + + replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { + if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { + throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') + } + const next = parseCodexResetCreditAttemptLedger(ledger) + const previous = this[primaryStateWriteOperationsContext].runtime.state + .codexResetCreditAttemptLedger + ? structuredClone( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + : undefined + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next + try { + this[primaryStateWriteOperationsContext].runtime.flushOrThrow() + } catch (error) { + // Why: callers use a successful return as the durability barrier before + // handing a scarce-credit mutation to the provider. + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = + previous + throw error + } + } +} + +export function enqueueWrite(owner: PrimaryStateWriteOperations): Promise { + const previousWrite = Promise.all([ + owner[primaryStateWriteOperationsContext].runtime.pendingWrite ?? + owner[primaryStateWriteOperationsContext].runtime.staleTempCleanup, + owner[primaryStateWriteOperationsContext].runtime.pendingSnapshotFileWork ?? Promise.resolve() + ]).then(() => {}) + const write = previousWrite.then(() => writeToDiskAsync(owner)) + const trackedWrite = write + .catch((err) => { + console.error('[persistence] Failed to write state:', err) + }) + .finally(() => { + if (owner[primaryStateWriteOperationsContext].runtime.pendingWrite === trackedWrite) { + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = null + } + }) + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = trackedWrite + return write +} + +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. + if (stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = durableWriteTempPath(dataFile) + + // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. + let renamed = false + try { + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } + // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== 'ENOENT' || + owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen + ) { + throw error + } + } finally { + if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } + } + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. + if (renamed && owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + } else if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null + } + if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (!renamed) { + return + } + // Why (#1158): rotate only after the primary rename while this write still owns its generation. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + await owner[primaryStateWriteOperationsContext].backups.rotateBackupsAsync(dataFile) +} + +export function writeToDiskSync( + owner: PrimaryStateWriteOperations, + opts: { force?: boolean; skipBackupRotation?: boolean } = {} +): void { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. + if ( + !opts.force && + stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash + ) { + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + + // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. + let renamed = false + try { + // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty + // content after power loss, losing projects/tabs back to the newest usable .bak slot. + writeFileDurableSync(tmpFile, dataFile, payload) + renamed = true + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + owner[primaryStateWriteOperationsContext].runtime.writeGeneration + ) + } finally { + if (!renamed) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup; the write already failed, swallow secondary error. + } + } + } + const now = Date.now() + if ( + !opts.skipBackupRotation && + owner[primaryStateWriteOperationsContext].backups.shouldRotateBackups(now, dataFile) + ) { + owner[primaryStateWriteOperationsContext].backups.rotateBackupsSync(dataFile) + } +} + +export function installPrimaryStateWriteOperationsContext( + target: object, + source: PrimaryStateWriteOperations +): void { + Object.defineProperty(target, primaryStateWriteOperationsContext, { + value: source[primaryStateWriteOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts new file mode 100644 index 00000000000..228229d2584 --- /dev/null +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -0,0 +1,194 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultOnboardingState } from '../../../shared/constants' +import type { FeatureInteractionId } from '../../../shared/feature-interactions' +import { + updateSettings as updateSettingsOperation, + type SettingsMutationOperations +} from '../applying-settings/settings-update' +import { getPersistedUI } from '../applying-settings/ui-state-read' +import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' +import { + recordFeatureInteraction as recordFeatureInteractionOperation, + type FeatureInteractionOperations +} from '../applying-settings/feature-interaction-recording' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type ProfilePreferencesRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'protectedSecrets' + | 'settingsChangeListeners' + | 'state' + | 'uiChangeListeners' +> + +const profilePreferencesContext = Symbol('ProfilePreferences') +type ProfilePreferencesContext = { + runtime: ProfilePreferencesRuntime + scheduling: WriteSchedulingOperations +} + +export class ProfilePreferences { + readonly [profilePreferencesContext]: ProfilePreferencesContext + + constructor(runtime: ProfilePreferencesRuntime, scheduling: WriteSchedulingOperations) { + this[profilePreferencesContext] = { runtime, scheduling } + } + + getSettings(): GlobalSettings { + return this[profilePreferencesContext].runtime.state.settings + } + + onSettingsChanged( + listener: ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + ): () => void { + this[profilePreferencesContext].runtime.settingsChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.settingsChangeListeners.delete(listener) + } + } + + onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { + this[profilePreferencesContext].runtime.uiChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.uiChangeListeners.delete(listener) + } + } + + updateSettings( + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} + ): GlobalSettings { + return updateSettingsOperation(getSettingsMutationOperations(this), updates, options) + } + + getUI(): PersistedState['ui'] { + return getPersistedUI( + this[profilePreferencesContext].runtime.state, + this[profilePreferencesContext].runtime.activeViewPreference.get() + ) + } + + updateUI(updates: Partial): void { + updatePersistedUI(getUIUpdateOperations(this), updates) + } + + recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { + return recordFeatureInteractionOperation(getFeatureInteractionOperations(this), id) + } + + getOnboarding(): PersistedState['onboarding'] { + const defaults = getDefaultOnboardingState() + return { + ...defaults, + ...this[profilePreferencesContext].runtime.state.onboarding, + checklist: { + ...defaults.checklist, + ...this[profilePreferencesContext].runtime.state.onboarding?.checklist + } + } + } + + updateOnboarding( + updates: Partial> & { + checklist?: Partial + } + ): PersistedState['onboarding'] { + const current = this.getOnboarding() + this[profilePreferencesContext].runtime.state.onboarding = { + ...current, + ...updates, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + scheduleSave(this[profilePreferencesContext].scheduling) + return this.getOnboarding() + } + + getGitHubCache(): PersistedState['githubCache'] { + return this[profilePreferencesContext].runtime.state.githubCache + } + + setGitHubCache(cache: PersistedState['githubCache']): void { + // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. + this[profilePreferencesContext].runtime.state.githubCache = cache + this[profilePreferencesContext].runtime.githubCacheDirty = true + this[profilePreferencesContext].runtime.githubCacheGeneration += 1 + } +} + +export function notifySettingsChanged( + owner: ProfilePreferences, + updates: Partial, + originWebContentsId?: number +): void { + for (const listener of owner[profilePreferencesContext].runtime.settingsChangeListeners) { + listener(updates, owner[profilePreferencesContext].runtime.state.settings, originWebContentsId) + } +} + +export function notifyUIChanged(owner: ProfilePreferences): void { + if (owner[profilePreferencesContext].runtime.uiChangeListeners.size === 0) { + return + } + const ui = owner.getUI() + for (const listener of owner[profilePreferencesContext].runtime.uiChangeListeners) { + listener(ui) + } +} + +export function getSettingsMutationOperations( + owner: ProfilePreferences +): SettingsMutationOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifySettingsChanged: (updates, originWebContentsId) => + notifySettingsChanged(owner, updates, originWebContentsId) + } +} + +export function getUIUpdateOperations(owner: ProfilePreferences): UIUpdateOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + setActiveView: (activeView) => + owner[profilePreferencesContext].runtime.activeViewPreference.set(activeView), + getUI: () => owner.getUI(), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner) + } +} + +export function getFeatureInteractionOperations( + owner: ProfilePreferences +): FeatureInteractionOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner), + getUI: () => owner.getUI() + } +} + +export function installProfilePreferencesContext(target: object, source: ProfilePreferences): void { + Object.defineProperty(target, profilePreferencesContext, { + value: source[profilePreferencesContext] + }) +} diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts new file mode 100644 index 00000000000..e7100478037 --- /dev/null +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -0,0 +1,233 @@ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs +} from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import { + hydrateRepo, + updateRepoBackedProjectHostSetup, + updateIndependentProjectHostSetup, + pruneMobileClientTabSelections +} from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' +import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operations' + +type ProjectCollectionOperationsRuntime = Pick< + StoreRuntimeState, + | 'folderWorkspaceOperations' + | 'gitUsernameCache' + | 'projectGroupOperations' + | 'projectHostOperations' + | 'state' +> + +const projectCollectionOperationsContext = Symbol('ProjectCollectionOperations') +type ProjectCollectionOperationsContext = { + runtime: ProjectCollectionOperationsRuntime + repos: RepoLifecycleOperations + scheduling: WriteSchedulingOperations + metadata: MetadataLineageOperations +} + +export class ProjectCollectionOperations { + readonly [projectCollectionOperationsContext]: ProjectCollectionOperationsContext + + constructor( + runtime: ProjectCollectionOperationsRuntime, + repos: RepoLifecycleOperations, + scheduling: WriteSchedulingOperations, + metadata: MetadataLineageOperations + ) { + this[projectCollectionOperationsContext] = { runtime, repos, scheduling, metadata } + } + + getRepos(): Repo[] { + return getProjectHostOperations(this).getRepos() + } + + getProjects(): Project[] { + return getProjectHostOperations(this).getProjects() + } + + updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { + return getProjectHostOperations(this).updateProject(id, updates) + } + + getProjectHostSetups(): ProjectHostSetup[] { + return getProjectHostOperations(this).getProjectHostSetups() + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + return getProjectHostOperations(this).createProjectHostSetup(args) + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + return getProjectHostOperations(this).updateProjectHostSetup(args) + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + return getProjectHostOperations(this).deleteProjectHostSetup(args) + } + + getRepoCount(): number { + return getProjectHostOperations(this).getRepoCount() + } + + getRepo(id: string): Repo | undefined { + return getProjectHostOperations(this).getRepo(id) + } + + setResolvedRepoGitUsername( + target: Pick, + username: string + ): boolean { + return getProjectHostOperations(this).setResolvedRepoGitUsername(target, username) + } + + getProjectGroups(): ProjectGroup[] { + return getProjectGroupOperations(this).getProjectGroups() + } + + createProjectGroup( + input: Parameters[0] + ): ProjectGroup { + return getProjectGroupOperations(this).createProjectGroup(input) + } + + updateProjectGroup( + groupId: string, + updates: Parameters[1] + ): ProjectGroup | null { + return getProjectGroupOperations(this).updateProjectGroup(groupId, updates) + } + + deleteProjectGroup(groupId: string): boolean { + return getProjectGroupOperations(this).deleteProjectGroup(groupId) + } + + getFolderWorkspaces(): FolderWorkspace[] { + return getFolderWorkspaceOperations(this).getFolderWorkspaces() + } + + getFolderWorkspace(id: string): FolderWorkspace | undefined { + return getFolderWorkspaceOperations(this).getFolderWorkspace(id) + } + + createFolderWorkspace( + input: Parameters[0] + ): FolderWorkspace { + return getFolderWorkspaceOperations(this).createFolderWorkspace(input) + } + + updateFolderWorkspace( + id: string, + updates: Parameters[1] + ): FolderWorkspace | null { + return getFolderWorkspaceOperations(this).updateFolderWorkspace(id, updates) + } + + removeFolderWorkspace(id: string): boolean { + return getFolderWorkspaceOperations(this).removeFolderWorkspace(id) + } + + moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { + return getFolderWorkspaceOperations(this).moveProjectToGroup(repoId, groupId, order) + } +} + +export function getProjectHostOperations( + owner: ProjectCollectionOperations +): ProjectHostPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectHostOperations ??= + new ProjectHostPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + gitUsernameCache: owner[projectCollectionOperationsContext].runtime.gitUsernameCache, + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo), + updateRepoBackedProjectHostSetup: (setup, repo, updates) => + updateRepoBackedProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + repo, + updates + ), + updateIndependentProjectHostSetup: (setup, updates) => + updateIndependentProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + updates + ), + removeProjectForHost: (id, hostId) => + owner[projectCollectionOperationsContext].repos.removeProjectForHost(id, hostId), + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling) + }) + return owner[projectCollectionOperationsContext].runtime.projectHostOperations +} + +export function getProjectGroupOperations( + owner: ProjectCollectionOperations +): ProjectGroupPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectGroupOperations ??= + new ProjectGroupPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ) + }) + return owner[projectCollectionOperationsContext].runtime.projectGroupOperations +} + +export function getFolderWorkspaceOperations( + owner: ProjectCollectionOperations +): FolderWorkspacePersistenceOperations { + owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations ??= + new FolderWorkspacePersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ), + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo) + }) + return owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations +} + +export function installProjectCollectionOperationsContext( + target: object, + source: ProjectCollectionOperations +): void { + Object.defineProperty(target, projectCollectionOperationsContext, { + value: source[projectCollectionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts new file mode 100644 index 00000000000..3a7af3da9f3 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -0,0 +1,271 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { + sameTerminalOwnerIdentity, + type TerminalOwnerIdentity +} from '../../../shared/terminal-owner-identity' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { + cloneWorkspaceSessionState, + createMinimalPersistedTerminalTab +} from '../restoring-sessions/session-owner-fields' + +import type { PtyBindingSourceExpectation } from './store' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { resolveHostId } from './session-host-partitions' + +type PtyBindingPersistenceOperationsRuntime = Pick + +const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') +type PtyBindingPersistenceOperationsContext = { + runtime: PtyBindingPersistenceOperationsRuntime + sessions: SessionHostPartitionOperations +} + +export class PtyBindingPersistenceOperations { + readonly [ptyBindingPersistenceOperationsContext]: PtyBindingPersistenceOperationsContext + + constructor( + runtime: PtyBindingPersistenceOperationsRuntime, + sessions: SessionHostPartitionOperations + ) { + this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } + } + + persistPtyBinding( + args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + ownerIdentity?: TerminalOwnerIdentity + startupCwd?: string + expectedBinding?: { + ptyId: string + incarnationId?: string + /** Present (including null) to compare the persisted owner tuple. */ + ownerIdentity?: TerminalOwnerIdentity | null + } + expectedSourceBinding?: PtyBindingSourceExpectation + /** Set by host-initiated creates, which have no renderer session writer behind them. */ + hostAdmittedMembership?: boolean + }, + hostId?: string | null + ): boolean { + const resolvedHostId = resolveHostId(hostId) + const session = + this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + if (args.expectedSourceBinding) { + const expected = args.expectedSourceBinding + if (expected.tabId !== args.tabId) { + return false + } + const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId + ) + const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] + const sourcePaneKey = `${expected.tabId}:${expected.leafId}` + if ( + !sourceTab || + sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || + !layoutContainsLeafId(sourceLayout.root, expected.leafId) || + (expected.incarnationId !== undefined && + session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) + ) { + return false + } + } + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + const expectedOwnerMatches = + !('ownerIdentity' in args.expectedBinding) || + (args.expectedBinding.ownerIdentity === null + ? session.terminalPtyOwnersByPaneKey?.[paneKey] === undefined + : sameTerminalOwnerIdentity( + session.terminalPtyOwnersByPaneKey?.[paneKey], + args.expectedBinding.ownerIdentity + )) + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== + args.expectedBinding.incarnationId || + !expectedOwnerMatches + ) { + return false + } + } + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: session + } + } + const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + const restoreSession = (): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSession = + sessionBeforeBinding + } else { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: sessionBeforeBinding + } + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + if (args.ownerIdentity) { + // A partial owner record invites inference, so persist the complete tuple atomically. + session.terminalPtyOwnersByPaneKey = { + ...session.terminalPtyOwnersByPaneKey, + [paneKey]: { ...args.ownerIdentity } + } + } else if (args.incarnationId) { + // A new authenticated session without owner metadata must not inherit an older owner. + session.terminalPtyOwnersByPaneKey = { ...session.terminalPtyOwnersByPaneKey } + delete session.terminalPtyOwnersByPaneKey[paneKey] + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = args.ptyId + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } +} + +export function installPtyBindingPersistenceOperationsContext( + target: object, + source: PtyBindingPersistenceOperations +): void { + Object.defineProperty(target, ptyBindingPersistenceOperationsContext, { + value: source[ptyBindingPersistenceOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts new file mode 100644 index 00000000000..bdd3718d9a6 --- /dev/null +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -0,0 +1,264 @@ +import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from '../../orca-profiles/profile-project-session-state' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility' +import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' +import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' +import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RepoLifecycleOperationsRuntime = Pick< + StoreRuntimeState, + | 'gitUsernameCache' + | 'projectHostSetupOperations' + | 'repoOrderOperations' + | 'repoUpdateOperations' + | 'state' +> + +const repoLifecycleOperationsContext = Symbol('RepoLifecycleOperations') +type RepoLifecycleOperationsContext = { + runtime: RepoLifecycleOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class RepoLifecycleOperations { + readonly [repoLifecycleOperationsContext]: RepoLifecycleOperationsContext + + constructor(runtime: RepoLifecycleOperationsRuntime, scheduling: WriteSchedulingOperations) { + this[repoLifecycleOperationsContext] = { runtime, scheduling } + } + + addRepo(repo: Repo): void { + getRepoOrderOperations(this).addRepo(repo) + } + + reorderRepos(orderedIds: string[]): boolean { + return getRepoOrderOperations(this).reorderRepos(orderedIds) + } + + reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { + return getRepoOrderOperations(this).reorderReposForHost(orderedIds, hostId) + } + + removeProject(id: string): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => r.id !== id) + syncProjectHostSetupCompatibilityState(this) + // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + pruneWorktreeStateForRepo(this, id, null) + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + removeProjectForHost(id: string, hostId: ExecutionHostId): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => !(r.id === id && getRepoExecutionHostId(r) === hostId)) + const idStillPresent = this[repoLifecycleOperationsContext].runtime.state.repos.some( + (r) => r.id === id + ) + // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. + if (!idStillPresent) { + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + } + syncProjectHostSetupCompatibilityState(this) + // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). + pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null) + if (!idStillPresent) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { + const session = + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + if (session) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: removeRepoFromWorkspaceSession(session, id) + } + } + } + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + updateRepo( + id: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'gitRemoteIdentity' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'executionHostId' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'forkSyncMode' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + | 'projectHostSetupMethod' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + }, + hostId?: ExecutionHostId + ): Repo | null { + return getRepoUpdateOperations(this).updateRepo(id, updates, hostId) + } +} + +export function getRepoOrderOperations( + owner: RepoLifecycleOperations +): RepoOrderPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoOrderOperations ??= + new RepoOrderPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.repoOrderOperations +} + +export function pruneWorktreeStateForRepo( + owner: RepoLifecycleOperations, + id: string, + hostId: ExecutionHostId | null +): void { + pruneWorktreeStateForRepoOperation( + owner[repoLifecycleOperationsContext].runtime.state, + id, + hostId, + (matchesWorktreeId) => pruneMobileClientTabSelections(owner, matchesWorktreeId) + ) +} + +export function pruneMobileClientTabSelections( + owner: RepoLifecycleOperations, + matchesWorktreeId: (worktreeId: string) => boolean +): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + owner[repoLifecycleOperationsContext].runtime.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const worktreeId of Object.keys(selectionsByWorktree)) { + if (matchesWorktreeId(worktreeId)) { + delete selectionsByWorktree[worktreeId] + } + } + if (Object.keys(selectionsByWorktree).length === 0) { + delete owner[repoLifecycleOperationsContext].runtime.state + .mobileClientTabSelectionsByDeviceId?.[clientNavigationId] + } + } +} + +export function getRepoUpdateOperations( + owner: RepoLifecycleOperations +): RepoUpdatePersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations ??= + new RepoUpdatePersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling), + hydrateRepo: (repo) => hydrateRepo(owner, repo) + }) + return owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations +} + +export function syncProjectHostSetupCompatibilityState(owner: RepoLifecycleOperations): void { + const compatibilityState = mergeProjectHostSetupCompatibilityState( + owner[repoLifecycleOperationsContext].runtime.state, + owner[repoLifecycleOperationsContext].runtime.state.repos + ) + owner[repoLifecycleOperationsContext].runtime.state.projects = compatibilityState.projects + owner[repoLifecycleOperationsContext].runtime.state.projectHostSetups = + compatibilityState.projectHostSetups +} + +export function getProjectHostSetupOperations( + owner: RepoLifecycleOperations +): ProjectHostSetupPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations ??= + new ProjectHostSetupPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + updateRepo: (id, updates, hostId) => owner.updateRepo(id, updates, hostId), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations +} + +export function updateRepoBackedProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] +): { setup: ProjectHostSetup; repo: Repo } | null { + return getProjectHostSetupOperations(owner).updateRepoBackedProjectHostSetup(setup, repo, updates) +} + +export function updateIndependentProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] +): ProjectHostSetup { + return getProjectHostSetupOperations(owner).updateIndependentProjectHostSetup(setup, updates) +} + +export function hydrateRepo(owner: RepoLifecycleOperations, repo: Repo): Repo { + return hydrateRepoOperation(repo, owner[repoLifecycleOperationsContext].runtime.gitUsernameCache) +} + +export function installRepoLifecycleOperationsContext( + target: object, + source: RepoLifecycleOperations +): void { + Object.defineProperty(target, repoLifecycleOperationsContext, { + value: source[repoLifecycleOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/retired-name-registry-normalization.ts b/src/main/persistence/loading-store/retired-name-registry-normalization.ts new file mode 100644 index 00000000000..d1c412f0f46 --- /dev/null +++ b/src/main/persistence/loading-store/retired-name-registry-normalization.ts @@ -0,0 +1,46 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { + clampExhaustedTiers, + compactRetiredNames, + isEmptyRetiredNameRegistry, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' + +function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { + const isPlainArray = Array.isArray(row) + const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined + const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] + const names = new Set() + for (const entry of rawNames) { + if (typeof entry !== 'string') { + continue + } + const normalized = normalizeRetirableGeneratedName(entry) + if (normalized) { + names.add(normalized) + } + } + return compactRetiredNames({ + exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), + names: [...names] + }) +} + +export function normalizeRetiredNameRegistryMap( + value: unknown +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const byRepo: Record = {} + for (const [repoId, row] of Object.entries(value as Record)) { + if (!repoId) { + continue + } + const registry = normalizeRetiredNameRegistry(row) + if (!isEmptyRetiredNameRegistry(registry)) { + byRepo[repoId] = registry + } + } + return byRepo +} diff --git a/src/main/persistence/loading-store/retired-worktree-name-persistence.ts b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts new file mode 100644 index 00000000000..c5260850522 --- /dev/null +++ b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts @@ -0,0 +1,119 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' +import { + addRetiredNames, + EMPTY_RETIRED_NAME_REGISTRY, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RetiredWorktreeNameRuntime = Pick + +const retiredWorktreeNamePersistenceContext = Symbol('RetiredWorktreeNamePersistence') +type RetiredWorktreeNamePersistenceContext = { + runtime: RetiredWorktreeNameRuntime + scheduling: WriteSchedulingOperations +} + +export class RetiredWorktreeNamePersistence { + readonly [retiredWorktreeNamePersistenceContext]: RetiredWorktreeNamePersistenceContext + + constructor(runtime: RetiredWorktreeNameRuntime, scheduling: WriteSchedulingOperations) { + this[retiredWorktreeNamePersistenceContext] = { runtime, scheduling } + } + + getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo?.[repoId] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace?.[ + namespaceKey + ] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + addRetiredWorktreeName(repoId: string, name: string): void { + const normalized = normalizeRetirableGeneratedName(name) + if (!repoId || !normalized) { + return + } + applyRetiredWorktreeNames(this, repoId, [normalized]) + } + + mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { + if (!repoId) { + return false + } + const incoming = new Set() + for (const name of names) { + const normalized = normalizeRetirableGeneratedName(name) + if (normalized) { + incoming.add(normalized) + } + } + return incoming.size > 0 && applyRetiredWorktreeNames(this, repoId, incoming) + } + + mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { + if (!namespaceKey) { + return false + } + const normalized = new Set() + for (const name of names) { + const candidate = normalizeRetirableGeneratedName(name) + if (candidate) { + normalized.add(candidate) + } + } + const next = addRetiredNames( + this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), + normalized + ) + if (!next) { + return false + } + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace ??= {} + recordRetirementNamespaceRegistry( + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace, + namespaceKey, + next + ) + scheduleSave(this[retiredWorktreeNamePersistenceContext].scheduling) + return true + } +} + +export function applyRetiredWorktreeNames( + owner: RetiredWorktreeNamePersistence, + repoId: string, + names: Iterable +): boolean { + const next = addRetiredNames(owner.getRetiredWorktreeNameRegistry(repoId), names) + if (!next) { + return false + } + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo ??= {} + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo[repoId] = + next + scheduleSave(owner[retiredWorktreeNamePersistenceContext].scheduling) + return true +} + +export function installRetiredWorktreeNamePersistenceContext( + target: object, + source: RetiredWorktreeNamePersistence +): void { + Object.defineProperty(target, retiredWorktreeNamePersistenceContext, { + value: source[retiredWorktreeNamePersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts new file mode 100644 index 00000000000..53c7ed5ea06 --- /dev/null +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -0,0 +1,190 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' +import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { + removeWorkspaceSessionOwner, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SessionHostPartitionOperationsRuntime = Pick< + StoreRuntimeState, + 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperations') +type SessionHostPartitionOperationsContext = { + runtime: SessionHostPartitionOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class SessionHostPartitionOperations { + readonly [sessionHostPartitionOperationsContext]: SessionHostPartitionOperationsContext + + constructor( + runtime: SessionHostPartitionOperationsRuntime, + scheduling: WriteSchedulingOperations + ) { + this[sessionHostPartitionOperationsContext] = { runtime, scheduling } + } + + getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSession ?? + getDefaultWorkspaceSession() + ) + } + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + resolved + ] ?? getDefaultWorkspaceSession() + ) + } + + getWorkspaceSessionHostIds(): ExecutionHostId[] { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const key of Object.keys( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId ?? {} + )) { + const hostId = normalizeExecutionHostId(key) + if (hostId) { + hostIds.add(hostId) + } + } + return [...hostIds] + } + + readTerminalScrollbackSnapshot(ref: string): string | null { + return readTerminalScrollbackSnapshotSync( + ref, + this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage + ) + } + + getWorktreeIdForTab(tabId: string): string | undefined { + return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) + } + + removeWorkspaceSessionStateForWorktree( + worktreeId: string, + hostId?: ExecutionHostId | null, + options: { advanceTerminalTopologyRevision?: boolean } = {} + ): void { + for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { + removeWorkspaceSessionOwnerInPartition(this, worktreeId, resolved, options) + } + } +} + +export function resolveHostId(hostId?: string | null): ExecutionHostId { + return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID +} + +export function hasPersistedWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId +): boolean { + return ( + hostId === LOCAL_EXECUTION_HOST_ID || + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + hostId + ] !== undefined + ) +} + +export function removeWorkspaceSessionOwnerInPartition( + owner: SessionHostPartitionOperations, + worktreeId: string, + resolved: ExecutionHostId, + options: { advanceTerminalTopologyRevision?: boolean } +): void { + if (!hasPersistedWorkspaceSession(owner, resolved)) { + return + } + const current = owner.getWorkspaceSession(resolved) + const session = removeWorkspaceSessionOwner(current, worktreeId, { + advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true + }) + if (!session) { + return + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSession = session + } else { + // Host scoping matters because identical repo/path ids may exist on two servers. + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: session + } + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function partitionOwnsWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + return owner.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined +} + +export function partitionHasOtherRepoWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const tabsByWorktree = owner.getWorkspaceSession(hostId).tabsByWorktree ?? {} + return Object.entries(tabsByWorktree).some( + ([id, tabs]) => + id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 + ) +} + +export function setHostWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId, + session: WorkspaceSessionState +): void { + // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. + session = sanitizeWorkspaceSessionTerminalRetirements( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + ) + const pruned = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.repos + ) + ) + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: pruned + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function installSessionHostPartitionOperationsContext( + target: object, + source: SessionHostPartitionOperations +): void { + Object.defineProperty(target, sessionHostPartitionOperationsContext, { + value: source[sessionHostPartitionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts new file mode 100644 index 00000000000..2f5f1c64b86 --- /dev/null +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -0,0 +1,102 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' + +import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session-cleanup' + +import { setLocalWorkspaceSession } from './workspace-session-snapshot-publication' +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { resolveHostId, setHostWorkspaceSession } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' + +type SessionSnapshotOperationsRuntime = Pick< + StoreRuntimeState, + 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') +type SessionSnapshotOperationsContext = { + runtime: SessionSnapshotOperationsRuntime + sessions: SessionHostPartitionOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SessionSnapshotOperations { + readonly [sessionSnapshotOperationsContext]: SessionSnapshotOperationsContext + + constructor( + runtime: SessionSnapshotOperationsRuntime, + sessions: SessionHostPartitionOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sessionSnapshotOperationsContext] = { runtime, sessions, bindingRecovery, scheduling } + } + + setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + stageWorkspaceSessionBeforeUnload( + session: PersistedState['workspaceSession'], + hostId?: string | null + ): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session, true) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { + const resolved = resolveHostId(hostId) + // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. + let next: WorkspaceSessionState = { + ...this[sessionSnapshotOperationsContext].sessions.getWorkspaceSession(resolved), + ...patch + } + if (workspaceSessionPatchNeedsFullNormalization(patch)) { + this.setWorkspaceSession(next, resolved) + return + } + if (Object.hasOwn(patch, 'browserUrlHistory')) { + next = pruneWorkspaceSessionBrowserHistory(next) + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this[sessionSnapshotOperationsContext].runtime.state.workspaceSession = next + } else { + this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: next + } + } + scheduleSave(this[sessionSnapshotOperationsContext].scheduling) + } +} + +export function getSessionSnapshotOperationsContext(owner: SessionSnapshotOperations) { + return owner[sessionSnapshotOperationsContext] +} + +export function installSessionSnapshotOperationsContext( + target: object, + source: SessionSnapshotOperations +): void { + Object.defineProperty(target, sessionSnapshotOperationsContext, { + value: source[sessionSnapshotOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/sparse-preset-persistence.ts b/src/main/persistence/loading-store/sparse-preset-persistence.ts new file mode 100644 index 00000000000..2de83313ecd --- /dev/null +++ b/src/main/persistence/loading-store/sparse-preset-persistence.ts @@ -0,0 +1,55 @@ +import type { SparsePreset } from '../../../shared/worktree/create-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SparsePresetRuntime = Pick + +const sparsePresetPersistenceContext = Symbol('SparsePresetPersistence') +type SparsePresetPersistenceContext = { + runtime: SparsePresetRuntime + scheduling: WriteSchedulingOperations +} + +export class SparsePresetPersistence { + readonly [sparsePresetPersistenceContext]: SparsePresetPersistenceContext + + constructor(runtime: SparsePresetRuntime, scheduling: WriteSchedulingOperations) { + this[sparsePresetPersistenceContext] = { runtime, scheduling } + } + + getSparsePresets(repoId: string): SparsePreset[] { + return [ + ...(this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? []) + ].sort((left, right) => left.name.localeCompare(right.name)) + } + + saveSparsePreset(preset: SparsePreset): SparsePreset { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] ?? [] + const index = existing.findIndex((entry) => entry.id === preset.id) + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] = + index === -1 + ? [...existing, preset] + : existing.map((entry, entryIndex) => (entryIndex === index ? preset : entry)) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + return preset + } + + removeSparsePreset(repoId: string, presetId: string): void { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? [] + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] = + existing.filter((entry) => entry.id !== presetId) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + } +} + +export function installSparsePresetPersistenceContext( + target: object, + source: SparsePresetPersistence +): void { + Object.defineProperty(target, sparsePresetPersistenceContext, { + value: source[sparsePresetPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts new file mode 100644 index 00000000000..e7cea585765 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -0,0 +1,182 @@ +import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../../shared/ssh-types' +import { + getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, + markSshRemotePtyLease as markSshRemotePtyLeaseOperation, + markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, + markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, + markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, + markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, + removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, + removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, + type SshPtyLeaseOperations, + upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation +} from '../leasing-ssh-ptys/ssh-pty-lease-operations' +import { + getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, + removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, + type SshPtyConsumerRecoveryOperations, + upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation +} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' +import { + clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, + clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, + type SshPtyBindingCleanupOperations +} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { flushDurableStateOrThrowAsync } from './write-flush-barriers' +import { scheduleSave } from './write-scheduling' + +type SshLeaseRecoveryOperationsRuntime = Pick + +const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') +type SshLeaseRecoveryOperationsContext = { + runtime: SshLeaseRecoveryOperationsRuntime + flushBarriers: WriteFlushBarrierOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SshLeaseRecoveryOperations { + readonly [sshLeaseRecoveryOperationsContext]: SshLeaseRecoveryOperationsContext + + constructor( + runtime: SshLeaseRecoveryOperationsRuntime, + flushBarriers: WriteFlushBarrierOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sshLeaseRecoveryOperationsContext] = { + runtime, + flushBarriers, + bindingRecovery, + scheduling + } + } + + getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { + return getSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { + await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record) + } + + async removeSshPtyConsumerRecovery(targetId: string): Promise { + await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { + return getSshRemotePtyLeasesOperation( + this[sshLeaseRecoveryOperationsContext].runtime.state, + targetId + ) + } + + upsertSshRemotePtyLease( + lease: Omit & + Partial> + ): void { + upsertSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), lease) + } + + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesForShutdownOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAsync( + targetId: string, + state: SshRemotePtyLease['state'] + ): Promise { + await markSshRemotePtyLeasesAsyncOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAttachedAsync( + targetId: string, + ptyIds: readonly string[] + ): Promise { + await markSshRemotePtyLeasesAttachedAsyncOperation( + getSshPtyLeaseOperations(this), + targetId, + ptyIds + ) + } + + markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId, state) + } + + removeSshRemotePtyLease(targetId: string, ptyId: string): void { + removeSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId) + } + + removeSshRemotePtyLeases(targetId: string): void { + removeSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId) + } +} + +export function getSshPtyConsumerRecoveryOperations( + owner: SshLeaseRecoveryOperations +): SshPtyConsumerRecoveryOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function getSshPtyBindingCleanupOperations( + owner: SshLeaseRecoveryOperations +): SshPtyBindingCleanupOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toComparablePtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( + targetId, + ptyId + ), + scheduleSave: () => scheduleSave(owner[sshLeaseRecoveryOperationsContext].scheduling) + } +} + +export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): SshPtyLeaseOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toStoredPtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseStorage( + targetId, + ptyId + ), + clearBindingsForTarget: (targetId) => + clearSshRemotePtyBindingsForTargetOperation( + getSshPtyBindingCleanupOperations(owner), + targetId + ), + clearBindingsForLeases: (targetId, leases) => + clearSshRemotePtyBindingsForLeasesOperation( + getSshPtyBindingCleanupOperations(owner), + targetId, + leases + ), + flush: () => owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush(), + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function installSshLeaseRecoveryOperationsContext( + target: object, + source: SshLeaseRecoveryOperations +): void { + Object.defineProperty(target, sshLeaseRecoveryOperationsContext, { + value: source[sshLeaseRecoveryOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts new file mode 100644 index 00000000000..5ab7c635f27 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -0,0 +1,142 @@ +import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' +import { + type SshTargetStateOperations, + addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, + addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, + addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, + addSshTarget as addSshTargetOperation, + clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, + getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, + getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, + getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, + getSshTarget as getSshTargetOperation, + getSshTargets as getSshTargetsOperation, + removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, + removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, + removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, + removeSshTarget as removeSshTargetOperation, + updateSshTarget as updateSshTargetOperation +} from '../leasing-ssh-ptys/ssh-target-state' +import { + reassignSshTargetId as reassignSshTargetIdOperation, + type SshTargetReassignmentOperations +} from '../leasing-ssh-ptys/ssh-target-reassignment' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' + +type SshProfileOperationsRuntime = Pick + +const sshProfileOperationsContext = Symbol('SshProfileOperations') +type SshProfileOperationsContext = { + runtime: SshProfileOperationsRuntime + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + repos: RepoLifecycleOperations +} + +export class SshProfileOperations { + readonly [sshProfileOperationsContext]: SshProfileOperationsContext + + constructor( + runtime: SshProfileOperationsRuntime, + scheduling: WriteSchedulingOperations, + flushBarriers: WriteFlushBarrierOperations, + repos: RepoLifecycleOperations + ) { + this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } + } + + getSshTargets(): SshTarget[] { + return getSshTargetsOperation(this[sshProfileOperationsContext].runtime.state) + } + + getSshTarget(id: string): SshTarget | undefined { + return getSshTargetOperation(this[sshProfileOperationsContext].runtime.state, id) + } + + addSshTarget(target: SshTarget): void { + addSshTargetOperation(getSshTargetStateOperations(this), target) + } + + updateSshTarget(id: string, updates: Partial>): SshTarget | null { + return updateSshTargetOperation(getSshTargetStateOperations(this), id, updates) + } + + removeSshTarget(id: string): void { + removeSshTargetOperation(getSshTargetStateOperations(this), id) + } + + getClaudeLivePtySessionIds(): string[] { + return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) + } + + addClaudeLivePtySessionId(sessionId: string): void { + addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + removeClaudeLivePtySessionId(sessionId: string): void { + removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + getDeletedSshConfigAliases(): string[] { + return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addDeletedSshConfigAlias(alias: string): void { + addDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + removeDeletedSshConfigAlias(alias: string): void { + removeDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + clearDeletedSshConfigAliases(): void { + clearDeletedSshConfigAliasesOperation(getSshTargetStateOperations(this)) + } + + getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { + return getRemovedSshTargetTombstonesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { + addRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), tombstone) + } + + removeRemovedSshTargetTombstone(oldTargetId: string): void { + removeRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), oldTargetId) + } + + reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { + const operations: SshTargetReassignmentOperations = { + state: this[sshProfileOperationsContext].runtime.state, + protectedSecrets: this[sshProfileOperationsContext].runtime.protectedSecrets, + syncProjectHostSetupCompatibilityState: () => + syncProjectHostSetupCompatibilityState(this[sshProfileOperationsContext].repos), + scheduleSave: () => scheduleSave(this[sshProfileOperationsContext].scheduling) + } + return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) + } +} + +export function getSshTargetStateOperations(owner: SshProfileOperations): SshTargetStateOperations { + return { + state: owner[sshProfileOperationsContext].runtime.state, + protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), + flush: () => owner[sshProfileOperationsContext].flushBarriers.flush() + } +} + +export function installSshProfileOperationsContext( + target: object, + source: SshProfileOperations +): void { + Object.defineProperty(target, sshProfileOperationsContext, { + value: source[sshProfileOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts new file mode 100644 index 00000000000..c9d81f071a5 --- /dev/null +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -0,0 +1,125 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from '../../protected-secret-persistence' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' + +import type { StoreRuntimeState } from './store-runtime-state' + +type StateSerializationSecretHandlingOperationsRuntime = Pick< + StoreRuntimeState, + 'protectedSecrets' | 'state' +> + +export class StateSerializationSecretHandlingOperations { + constructor(private readonly runtime: StateSerializationSecretHandlingOperationsRuntime) {} + + getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.runtime.state + return durable + } + + buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { + // Why sentinels (not a blob/key string match): the substitution must be + // position-exact. A plain search for the ciphertext — or even for a + // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an + // agentDefaultEnv var named after a secret field, or a value equal to a + // ciphertext), which would substitute the wrong site and let two DISTINCT + // states normalize equal → a silently dropped write (data loss), reachable + // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot + // random UUID can't occur anywhere else in the serialized state (the user + // sets their data before it is minted), so it appears exactly once. + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.runtime.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { + return blob + } + const sentinel = `orca-secret-slot-${randomUUID()}` + secretSubs.push({ sentinel, blob, hashValue }) + return sentinel + } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } + // Why: clone before encrypting secrets so in-memory this.state stays plaintext. + const stateToSave = { + ...this.getDurableState(), + // Why both keys unconditionally: the explicit keys always win over the spread, and + // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. + // The strip builds a new array here only; this.state records keep their notes in memory. + folderWorkspaces: (this.runtime.state.folderWorkspaces ?? []).map( + ({ diffComments: _relocated, ...rest }) => rest + ), + folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments( + this.runtime.state.folderWorkspaces + ), + sshPtyConsumerRecoveries: (this.runtime.state.sshPtyConsumerRecoveries ?? []).map( + (record) => ({ + ...record, + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) + }) + ), + settings: { + ...stripRetiredGlobalSettings(this.runtime.state.settings), + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.runtime.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.runtime.state.settings.httpProxyUrl ?? '' + ) + }, + ui: { + ...this.runtime.state.ui, + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.runtime.state.ui.browserKagiSessionLink + ) + } + } + // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. + // One full-state stringify; secret slots currently hold sentinels. + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } + } +} diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts new file mode 100644 index 00000000000..425ee2f1745 --- /dev/null +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -0,0 +1,187 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import { LoadedStateAdaptationOperations } from './loaded-state-adaptation' +import { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' +import { LoadedStateParsingOperations } from './loaded-state-parsing' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import { + PrimaryStateWriteOperations, + installPrimaryStateWriteOperationsContext +} from './primary-state-writes' +import { + WriteSchedulingOperations, + installWriteSchedulingOperationsContext +} from './write-scheduling' +import { + WriteFlushBarrierOperations, + installWriteFlushBarrierOperationsContext +} from './write-flush-barriers' +import { ProfilePreferences, installProfilePreferencesContext } from './profile-preferences' +import { + RepoLifecycleOperations, + installRepoLifecycleOperationsContext +} from './repo-lifecycle-operations' +import { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import { + SessionHostPartitionOperations, + installSessionHostPartitionOperationsContext +} from './session-host-partitions' +import { + SessionSnapshotOperations, + installSessionSnapshotOperationsContext +} from './session-snapshot-operations' +import { + MetadataLineageOperations, + installMetadataLineageOperationsContext +} from './metadata-lineage-operations' +import { + ProjectCollectionOperations, + installProjectCollectionOperationsContext +} from './project-collection-operations' +import { + AutomationPersistence, + installAutomationPersistenceContext +} from './automation-persistence' +import { + MobileTabSelectionPersistence, + installMobileTabSelectionPersistenceContext +} from './mobile-tab-selection-persistence' +import { + SparsePresetPersistence, + installSparsePresetPersistenceContext +} from './sparse-preset-persistence' +import { + PtyBindingPersistenceOperations, + installPtyBindingPersistenceOperationsContext +} from './pty-binding-persistence' +import { SshProfileOperations, installSshProfileOperationsContext } from './ssh-profile-operations' +import { + RetiredWorktreeNamePersistence, + installRetiredWorktreeNamePersistenceContext +} from './retired-worktree-name-persistence' +import { + SshLeaseRecoveryOperations, + installSshLeaseRecoveryOperationsContext +} from './ssh-lease-recovery-operations' + +export type StoreDomains = { + adaptation: LoadedStateAdaptationOperations + backups: BackupRecoveryRotationOperations + cohorts: LoadedCohortMigrationOperations + loader: LoadedStateParsingOperations + serialization: StateSerializationSecretHandlingOperations + writes: PrimaryStateWriteOperations + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences + repos: RepoLifecycleOperations + bindingRecovery: TerminalBindingRecoveryOperations + sessions: SessionHostPartitionOperations + sessionSnapshots: SessionSnapshotOperations + metadata: MetadataLineageOperations + projects: ProjectCollectionOperations + automations: AutomationPersistence + mobileTabSelections: MobileTabSelectionPersistence + sparsePresets: SparsePresetPersistence + ptyBindings: PtyBindingPersistenceOperations + sshProfiles: SshProfileOperations + retiredWorktreeNames: RetiredWorktreeNamePersistence + sshLeases: SshLeaseRecoveryOperations +} + +export const STORE_DOMAIN_OPERATION_CLASSES = [ + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations +] as const + +export function installStoreDomainContexts(target: object, domains: StoreDomains): void { + installWriteSchedulingOperationsContext(target, domains.scheduling) + installPrimaryStateWriteOperationsContext(target, domains.writes) + installProjectCollectionOperationsContext(target, domains.projects) + installRepoLifecycleOperationsContext(target, domains.repos) + installMobileTabSelectionPersistenceContext(target, domains.mobileTabSelections) + installSparsePresetPersistenceContext(target, domains.sparsePresets) + installAutomationPersistenceContext(target, domains.automations) + installMetadataLineageOperationsContext(target, domains.metadata) + installProfilePreferencesContext(target, domains.preferences) + installSessionHostPartitionOperationsContext(target, domains.sessions) + installSessionSnapshotOperationsContext(target, domains.sessionSnapshots) + installPtyBindingPersistenceOperationsContext(target, domains.ptyBindings) + installSshProfileOperationsContext(target, domains.sshProfiles) + installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) + installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) + installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) +} + +export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { + const adaptation = new LoadedStateAdaptationOperations(runtime) + const backups = new BackupRecoveryRotationOperations(runtime) + const cohorts = new LoadedCohortMigrationOperations(runtime) + const loader = new LoadedStateParsingOperations(runtime, backups, cohorts) + const serialization = new StateSerializationSecretHandlingOperations(runtime) + const writes = new PrimaryStateWriteOperations(runtime, serialization, backups) + const scheduling = new WriteSchedulingOperations(runtime, writes) + const flushBarriers = new WriteFlushBarrierOperations(runtime, writes) + const preferences = new ProfilePreferences(runtime, scheduling) + const repos = new RepoLifecycleOperations(runtime, scheduling) + const bindingRecovery = new TerminalBindingRecoveryOperations(runtime) + const sessions = new SessionHostPartitionOperations(runtime, scheduling) + const sessionSnapshots = new SessionSnapshotOperations( + runtime, + sessions, + bindingRecovery, + scheduling + ) + const metadata = new MetadataLineageOperations(runtime, scheduling, sessions) + const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) + const automations = new AutomationPersistence(runtime, flushBarriers, preferences) + const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) + const sparsePresets = new SparsePresetPersistence(runtime, scheduling) + const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) + const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) + const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) + const sshLeases = new SshLeaseRecoveryOperations( + runtime, + flushBarriers, + bindingRecovery, + scheduling + ) + return { + adaptation, + backups, + cohorts, + loader, + serialization, + writes, + scheduling, + flushBarriers, + preferences, + repos, + bindingRecovery, + sessions, + sessionSnapshots, + metadata, + projects, + automations, + mobileTabSelections, + sparsePresets, + ptyBindings, + sshProfiles, + retiredWorktreeNames, + sshLeases + } +} diff --git a/src/main/persistence/loading-store/store-owned-state.ts b/src/main/persistence/loading-store/store-owned-state.ts deleted file mode 100644 index 9c752b06f92..00000000000 --- a/src/main/persistence/loading-store/store-owned-state.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PersistedState } from '../../../shared/persisted-state-types' - -/** - * The Store's live state object, never a copy. Extracted operations read and mutate it in place, so a caller that - * passes a clone or a spread silently loses every write. The Store stays its sole owner and is the only thing that - * may hand this reference out. - */ -export type StoreOwnedPersistedState = PersistedState diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts new file mode 100644 index 00000000000..284f556ae41 --- /dev/null +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -0,0 +1,79 @@ +import { removeStaleDurableWriteTempFiles } from '../../durable-file-write' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ActiveViewPreference } from '../../active-view-preference' +import { + getProfileTerminalScrollbackSnapshotRoot, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' +import { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { getDataFile, getGithubCacheFile } from './user-data-path' +import { STALE_DURABLE_WRITE_TEMP_AGE_MS } from '../tracking-repos/worktree-metadata-normalization' +import type { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import type { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import type { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import type { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' +import type { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import type { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +export type StoreRuntimeOptions = { dataFile?: string } + +/** Mutable coordination state shared only with this Store's private collaborators. */ +export class StoreRuntimeState { + state!: PersistedState + readonly dataFile: string + activeViewPreference!: ActiveViewPreference + readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage + writeTimer: ReturnType | null = null + pendingWrite: Promise | null = null + pendingSnapshotFileWork: Promise | null = null + readonly staleTempCleanup: Promise + writeGeneration = 0 + inFlightAsyncTmpFile: string | null = null + backupRotationInFlight = false + writesFrozen = false + quitFlushStarted = false + quitFlushPromise: Promise | null = null + lastWrittenStateHash: string | null = null + lastDurableWriteGeneration = -1 + firstPendingSaveAt: number | null = null + githubCacheDirty = false + githubCacheGeneration = 0 + pendingGithubCacheWrite: Promise | null = null + readonly staleGithubCacheTempCleanup: Promise + readonly gitUsernameCache = new Map() + readonly protectedSecrets = new ProtectedSecretPersistence() + loadNeedsSave = false + flushOrThrow!: () => void + settingsChangeListeners = new Set< + ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + >() + uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() + projectHostOperations: ProjectHostPersistenceOperations | null = null + projectGroupOperations: ProjectGroupPersistenceOperations | null = null + folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null + repoOrderOperations: RepoOrderPersistenceOperations | null = null + repoUpdateOperations: RepoUpdatePersistenceOperations | null = null + projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null + + constructor(options: StoreRuntimeOptions = {}) { + this.dataFile = options.dataFile ?? getDataFile() + this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { + minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS + }) + this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( + getGithubCacheFile(this.dataFile), + { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } + ) + const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) + const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) + this.terminalScrollbackSnapshotStorage = { + snapshotRoot: profileSnapshotRoot, + fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot + } + } +} diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 006c4d9f3db..c66615a03df 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,509 +1,39 @@ -/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - renameSync, - unlinkSync, - copyFileSync, - statSync -} from 'node:fs' -import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' -import { - durableWriteTempPath, - removeStaleDurableWriteTempFiles, - renameDurable, - writeFileDurableSync -} from '../../durable-file-write' import { dirname } from 'node:path' -import { homedir } from 'node:os' -import { createHash, randomUUID } from 'node:crypto' -import type { - Automation, - AutomationCreateInput, - AutomationDispatchResult, - AutomationRun, - AutomationRunTrigger, - AutomationUpdateInput -} from '../../../shared/automations-types' -import { normalizeProxyUrl } from '../../../shared/network-proxy' -import { normalizeKagiSessionLink } from '../../../shared/browser-url' -import type { FolderWorkspace, WorkspaceKey } from '../../../shared/folder-workspace-types' -import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' -import type { - PersistedMobileClientTabSelections, - PersistedState -} from '../../../shared/persisted-state-types' -import type { ProjectGroup } from '../../../shared/project-group-types' -import type { - Project, - ProjectHostSetup, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - ProjectUpdateArgs -} from '../../../shared/project-types' -import type { Repo } from '../../../shared/repo-types' -import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import type { SparsePreset } from '../../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' -import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' -import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' -import { - removeRepoFromHostWorkspaceSessions, - removeRepoFromWorkspaceSession -} from '../../orca-profiles/profile-project-session-state' -import type { - RemovedSshTargetTombstone, - SshPtyConsumerRecovery, - SshRemotePtyLease, - SshTarget -} from '../../../shared/ssh-types' -import { isFolderRepo } from '../../../shared/repo-kind' -import { - getRepoExecutionHostId, - parseExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { - getDefaultPersistedState, - getDefaultOnboardingState, - getDefaultVoiceSettings, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - isDefaultedCompactWorktreeCardProperties, - normalizeWorktreeCardProperties -} from '../../../shared/constants' -import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' -import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' -import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' -import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import { toRelaySshPtyId } from '../../providers/ssh-pty-id' -import { isTerminalLeafId } from '../../../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from '../../agent-hooks/migration-unsupported-pty-state' import { agentHookServer } from '../../agent-hooks/server' -import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' -import { - backfillAutomationRunNumbers, - pruneAutomationRuns -} from '../../../shared/automation-run-retention' -import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' -import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' -import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' -import { - addRetiredNames, - clampExhaustedTiers, - compactRetiredNames, - EMPTY_RETIRED_NAME_REGISTRY, - isEmptyRetiredNameRegistry, - type RetiredNameRegistry -} from '../../../shared/worktree/retired-name-registry' -import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id' -import { - sameTerminalOwnerIdentity, - type TerminalOwnerIdentity -} from '../../../shared/terminal-owner-identity' -import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' -import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' -import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' -import { normalizeTaskProviderSettings } from '../../../shared/task-providers' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' -import { - addMobilePairingCustomAddress, - normalizeMobilePairingCustomAddress, - normalizeMobilePairingCustomAddresses -} from '../../../shared/mobile-pairing-custom-address' -import { normalizeOpenInApplications } from '../../../shared/open-in-applications' -import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' -import { normalizeAppIconId } from '../../../shared/app-icon' -import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { - normalizeFeatureInteractionTelemetryBuckets, - type FeatureInteractionId -} from '../../../shared/feature-interactions' -import { - parseCodexResetCreditAttemptLedger, - type CodexResetCreditAttemptLedger -} from '../../../shared/codex-reset-credit-attempt-ledger' -import { - DEFAULT_WORKSPACE_STATUS_ID, - normalizePersistedWorkspaceStatuses -} from '../../../shared/workspace-statuses' -import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' -import { - clearMissingProjectGroupMemberships, - createProjectGroup, - normalizeProjectGroups -} from '../../../shared/project-groups' -import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' -import { - mergeLegacyCommitMessageAiIntoSourceControlAi, - projectSourceControlAiToLegacyCommitMessageAi, - sourceControlAiSettingsFromLegacy -} from '../../../shared/source-control-ai' -import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' -import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' -import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../shared/osc52-clipboard-settings' -import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' -import { normalizeUiLanguage } from '../../../shared/ui-language' import { ActiveViewPreference } from '../../active-view-preference' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { normalizePersistedPaneIdentityState } from '../restoring-sessions/workspace-pane-normalization' +import { StoreRuntimeState } from './store-runtime-state' import { - collectFolderWorkspaceDiffComments, - normalizeFolderWorkspaceDiffComments -} from '../../folder-workspace-diff-comments' -import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { - collectTerminalScrollbackSnapshotRefs, - deleteTerminalScrollbackSnapshotSync, - getProfileTerminalScrollbackSnapshotRoot, - migrateWorkspaceSessionTerminalScrollbackSnapshots, - readTerminalScrollbackSnapshotSync, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' -import { - deleteRemovedTerminalScrollbackSnapshotsAsync, - migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync -} from '../../terminal-scrollback-snapshot-async-migration' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic -} from '../../startup/startup-diagnostics' -import { - PROTECTED_SECRET_SLOT, - ProtectedSecretPersistence, - sshPtyOwnerLeaseSecretSlot, - type ProtectedSecretRetentionUpdate -} from '../../protected-secret-persistence' -import { - isLegacyOpenCodeSessionCookie, - isLegacySshPtyOwnerLease -} from '../leasing-ssh-ptys/secret-validation' -import { getDataFile, getGithubCacheFile, readGithubCacheSnapshot } from './user-data-path' -import { - STALE_DURABLE_WRITE_TEMP_AGE_MS, - gcStaleWorktreeMeta, - normalizeWorktreeLinkedItemMetadata -} from '../tracking-repos/worktree-metadata-normalization' -import { - migrateAgentYoloDefaults, - migrateTerminalScrollbackRows, - migrateTerminalTuiScrollSensitivityDefault, - stripRetiredGlobalSettings -} from '../applying-settings/terminal-settings-migrations' -import { - normalizeRightSidebarExplorerView, - normalizeRightSidebarTab, - normalizeShowDotfilesByWorktree, - normalizeSortBy -} from '../applying-settings/ui-selection-normalization' -import { - normalizeWorkspaceLineageByChildKey, - stripMainOwnedTelemetryMarkerFromUI -} from '../applying-settings/ui-interaction-merge' -import { - backfillLegacyAutomationContexts, - normalizeAutomationRunWorkspaceDisplayName -} from '../scheduling-automations/automation-context-migration' -import { - normalizeLoadedOnboardingState, - normalizeNotificationSettings, - persistedNotificationSettingsRepaired, - readDeprecatedExperimentFlag, - readLegacySidekickFlag, - resolveSetupGuideSidebarDismissedOnLoad -} from '../applying-settings/onboarding-normalization' -import { - canonicalizePersistedFloatingWorkspaceDirectory, - normalizeFloatingWorkspaceTrustedCwds -} from '../restoring-sessions/floating-workspace-normalization' -import { - ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, - normalizeSshPtyConsumerRecovery, - normalizeSshRemotePtyLease, - normalizeSshTarget -} from '../leasing-ssh-ptys/ssh-normalization' -import { - type SshTargetStateOperations, - addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, - addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, - addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, - addSshTarget as addSshTargetOperation, - clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, - getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, - getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, - getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, - getSshTarget as getSshTargetOperation, - getSshTargets as getSshTargetsOperation, - removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, - removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, - removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, - removeSshTarget as removeSshTargetOperation, - updateSshTarget as updateSshTargetOperation -} from '../leasing-ssh-ptys/ssh-target-state' -import { - reassignSshTargetId as reassignSshTargetIdOperation, - type SshTargetReassignmentOperations -} from '../leasing-ssh-ptys/ssh-target-reassignment' -import { - getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, - markSshRemotePtyLease as markSshRemotePtyLeaseOperation, - markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, - markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, - markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, - markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, - removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, - removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, - type SshPtyLeaseOperations, - upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation -} from '../leasing-ssh-ptys/ssh-pty-lease-operations' -import { - getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, - removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, - type SshPtyConsumerRecoveryOperations, - upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation -} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' -import { - clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, - clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, - type SshPtyBindingCleanupOperations -} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' -import { - cloneLayoutNode, - layoutContainsLeafId, - preserveMissingLeafRecordEntries -} from '../restoring-sessions/terminal-layout-normalization' -import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' -import { - normalizeClaudeLivePtySessionIds, - normalizeLegacyPaneKeyAliasEntries, - normalizeMigrationUnsupportedPtyEntries, - registerPersistedPaneKeyAlias -} from '../restoring-sessions/pane-alias-normalization' -import { - normalizePersistedPaneIdentityState, - normalizeWorkspaceSessionPaneIdentities, - remapAcknowledgedAgentPaneKeys, - remapSshRemotePtyLeaseLeafIds, - type WorkspaceSessionPaneIdentityRemap -} from '../restoring-sessions/workspace-pane-normalization' -import { - mergeProjectHostSetupCompatibilityState, - projectHostSetupCompatibilityStateEqual -} from '../tracking-repos/project-host-compatibility' -import { - cloneWorkspaceSessionState, - createMinimalPersistedTerminalTab -} from '../restoring-sessions/session-owner-fields' -import { - removeWorkspaceSessionOwner, - workspaceSessionOwnerPartitionForHost, - workspaceSessionPartitionIdsForHost -} from '../restoring-sessions/session-owner-removal' -import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' -import { - createAutomation as createAutomationOperation, - deleteAutomation as deleteAutomationOperation, - listAutomations as listAutomationsOperation, - updateAutomation as updateAutomationOperation, - type AutomationDefinitionOperations -} from '../scheduling-automations/automation-definition-operations' -import { - createAutomationRun as createAutomationRunOperation, - listAutomationRuns as listAutomationRunsOperation, - snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, - updateAutomationRun as updateAutomationRunOperation, - type AutomationRunOperations -} from '../scheduling-automations/automation-run-operations' -import { - advanceAutomationNextRun as advanceAutomationNextRunOperation, - getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation -} from '../scheduling-automations/automation-schedule-operations' -import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' -import { - updateSettings as updateSettingsOperation, - type SettingsMutationOperations -} from '../applying-settings/settings-update' -import { getPersistedUI } from '../applying-settings/ui-state-read' -import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' -import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' -import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' -import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' -import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' -import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' -import { - recordFeatureInteraction as recordFeatureInteractionOperation, - type FeatureInteractionOperations -} from '../applying-settings/feature-interaction-recording' -import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' -import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' -import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' - -// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 -const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ - 'tabsByWorktree', - 'terminalLayoutsByTabId' -]) - -function logPersistenceStartupMilestone( - event: string, - details: Record = {} -): void { - if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) - } -} - -function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { - return Object.keys(patch).some((key) => - WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) - ) -} - -function workspaceSessionSalvageLogDetails(result: { - droppedCount: number - droppedPaths: string[] -}): { count: number; fields: string[]; detailsTruncated: boolean } { - return { - count: result.droppedCount, - fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], - detailsTruncated: result.droppedCount > result.droppedPaths.length - } -} - -/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. - * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ -function parseWorkspaceSessionsByHostId( - raw: unknown, - defaults: WorkspaceSessionState -): { partitions: Partial>; repaired: boolean } { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { partitions: {}, repaired: raw !== undefined } - } - let repaired = false - const partitions: Partial> = {} - for (const [key, value] of Object.entries(raw as Record)) { - const hostId = normalizeExecutionHostId(key) - // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. - if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { - continue - } - const result = parseWorkspaceSessionSalvaging(value) - if (!result.ok) { - repaired = true - console.error( - `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, - result.error - ) - continue - } - if (result.droppedCount > 0) { - console.warn( - `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, - workspaceSessionSalvageLogDetails(result) - ) - repaired = true - } - partitions[hostId] = { ...defaults, ...result.value } - } - return { partitions, repaired } -} - -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} - -/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ -async function exists(path: string): Promise { - return access(path).then( - () => true, - () => false - ) -} - -function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { - const isPlainArray = Array.isArray(row) - const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined - const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] - const names = new Set() - for (const entry of rawNames) { - if (typeof entry !== 'string') { - continue - } - const normalized = normalizeRetirableGeneratedName(entry) - if (normalized) { - names.add(normalized) - } - } - return compactRetiredNames({ - exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), - names: [...names] - }) -} - -function normalizeRetiredNameRegistryMap(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const byRepo: Record = {} - for (const [repoId, row] of Object.entries(value as Record)) { - if (!repoId) { - continue - } - const registry = normalizeRetiredNameRegistry(row) - if (!isEmptyRetiredNameRegistry(registry)) { - byRepo[repoId] = registry - } - } - return byRepo -} - -function deleteRemovedTerminalScrollbackSnapshots( - prior: WorkspaceSessionState | undefined, - next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage -): void { - if (!prior) { - return - } - const nextRefs = collectTerminalScrollbackSnapshotRefs(next) - for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - if (!nextRefs.has(ref)) { - deleteTerminalScrollbackSnapshotSync(ref, storage) - } - } -} - -export type StoreOptions = { - dataFile?: string -} + createStoreDomains, + installStoreDomainContexts, + STORE_DOMAIN_OPERATION_CLASSES, + type StoreDomains +} from './store-domain-composition' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { scheduleSave } from './write-scheduling' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import type { ProjectCollectionOperations } from './project-collection-operations' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' +import type { SparsePresetPersistence } from './sparse-preset-persistence' +import type { AutomationPersistence } from './automation-persistence' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import type { ProfilePreferences } from './profile-preferences' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { SessionSnapshotOperations } from './session-snapshot-operations' +import type { PtyBindingPersistenceOperations } from './pty-binding-persistence' +import type { SshProfileOperations } from './ssh-profile-operations' +import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' +import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +export type StoreOptions = { dataFile?: string } export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -512,69 +42,28 @@ export type PtyBindingSourceExpectation = { incarnationId?: string } +/** Concrete composition root for profile persistence. */ +// oxlint-disable-next-line typescript-eslint/no-unsafe-declaration-merging -- Store installs the exact concrete domain class descriptors and contexts below export class Store { - // Why readonly: the operations wrappers below capture this reference once and are memoized. + private readonly runtime: StoreRuntimeState + private readonly domains: StoreDomains private readonly state: PersistedState - private readonly dataFile: string - private readonly activeViewPreference: ActiveViewPreference - private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage - private writeTimer: ReturnType | null = null - private pendingWrite: Promise | null = null - private pendingSnapshotFileWork: Promise | null = null - private readonly staleTempCleanup: Promise - private writeGeneration = 0 - private inFlightAsyncTmpFile: string | null = null - // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. - private backupRotationInFlight = false - // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. - private writesFrozen = false - /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ - private quitFlushStarted = false - private quitFlushPromise: Promise | null = null - // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. - private lastWrittenStateHash: string | null = null - private lastDurableWriteGeneration = -1 - private firstPendingSaveAt: number | null = null - private githubCacheDirty = false - private githubCacheGeneration = 0 - private pendingGithubCacheWrite: Promise | null = null - private readonly staleGithubCacheTempCleanup: Promise - private readonly gitUsernameCache = new Map() - private readonly protectedSecrets = new ProtectedSecretPersistence() - private loadNeedsSave = false - private settingsChangeListeners = new Set< - ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - >() - private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() constructor(options: StoreOptions = {}) { - // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. - this.dataFile = options.dataFile ?? getDataFile() - this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { - minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS - }) - this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( - getGithubCacheFile(this.dataFile), - { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } - ) - const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) - const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) - this.terminalScrollbackSnapshotStorage = { - snapshotRoot: profileSnapshotRoot, - fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot - } - const loaded = this.load() + this.runtime = new StoreRuntimeState(options) + this.domains = createStoreDomains(this.runtime) + installStoreDomainContexts(this, this.domains) + this.runtime.flushOrThrow = () => this.flushOrThrow() + const loaded = this.domains.loader.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state - // Why: activeView is a frequent, tiny preference; keeping it beside the - // profile avoids serializing the multi-MB recovery store on navigation. - this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) - const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() - this.hydrateFolderWorkspaceDiffComments() + this.runtime.state = this.state + this.runtime.activeViewPreference = new ActiveViewPreference( + this.runtime.dataFile, + this.state.ui?.activeView + ) + const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() + this.domains.adaptation.hydrateFolderWorkspaceDiffComments() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -583,3370 +72,55 @@ export class Store { } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { this.state.legacyPaneKeyAliasEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { - // Why: rewrite legacy pane:1 leaves so older renderer writes can't revive them; other migrations also set loadNeedsSave. - this.scheduleSave() + if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + scheduleSave(this.domains.scheduling) } } - // Why: notes live top-level on disk so an older build's field-by-field - // normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here. - private hydrateFolderWorkspaceDiffComments(): void { - const stored = this.state.folderWorkspaceDiffComments - let relocatedInline = false - for (const workspace of this.state.folderWorkspaces ?? []) { - if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { - // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the - // older map untouched, so inline is the last notes-aware write. Also makes the relocation - // durable even if the user never edits anything this session. - relocatedInline = true - continue - } - const comments = stored?.[workspace.id] - // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. - if (Array.isArray(comments) && comments.length > 0) { - workspace.diffComments = comments - } - } - if (relocatedInline) { - this.loadNeedsSave = true - } - // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in - // state would make it a stale second source of truth that getDurableState() spreads back out. - delete this.state.folderWorkspaceDiffComments - } - - private adaptFlatFolderScanProjectGroups(): boolean { - // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. - const groups = this.state.projectGroups ?? [] - const repos = this.state.repos - if (groups.length === 0 || repos.length === 0) { - return false - } - - let changed = false - let maxOrder = -1 - for (const group of groups) { - maxOrder = Math.max(maxOrder, group.tabOrder) - } - - const childGroupIds = new Set( - groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) - ) - const initialGroupCount = groups.length - for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { - const rootGroup = groups[groupIndex] - if (!rootGroup) { - continue - } - if ( - rootGroup.createdFrom !== 'folder-scan' || - !rootGroup.parentPath || - rootGroup.parentGroupId || - childGroupIds.has(rootGroup.id) - ) { - continue - } - const rootPath = rootGroup.parentPath - const repoCandidates = repos.filter( - (repo) => - !isFolderRepo(repo) && - repo.projectGroupId === rootGroup.id && - isPathInsideOrEqual(rootPath, repo.path) - ) - if (repoCandidates.length < 2) { - continue - } - - const resolver = createNestedProjectGroupResolver({ - parentPath: rootPath, - groupName: rootGroup.name, - mode: 'group', - repoPaths: repoCandidates.map((repo) => repo.path), - createGroup: (input) => { - if (!input.parentGroupId) { - return rootGroup - } - maxOrder += 1 - const group = createProjectGroup({ - ...input, - tabOrder: maxOrder - }) - groups.push(group) - changed = true - return group - } - }) - const nextOrderByGroupId = new Map() - for (const repo of repoCandidates) { - const group = resolver.getGroupForRepo(repo.path) - if (!group) { - continue - } - const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 - nextOrderByGroupId.set(group.id, nextOrder + 1) - if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { - repo.projectGroupId = group.id - repo.projectGroupOrder = nextOrder - changed = true - } - } - } - return changed - } - - // Why (#1158): debounced writes fire ~every 300ms; throttle backups to distinct moments, not near-identical snapshots. - private shouldRotateBackups(now: number, dataFile: string): boolean { - try { - const mtime = statSync(backupPath(dataFile, 0)).mtimeMs - return now - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible - // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. - private async shouldRotateBackupsAsync(dataFile: string): Promise { - try { - const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs - return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. - private async rotateBackupsAsync(dataFile: string): Promise { - if (this.backupRotationInFlight) { - return - } - this.backupRotationInFlight = true - try { - if (!(await this.shouldRotateBackupsAsync(dataFile))) { - return - } - if (!(await exists(dataFile))) { - return - } - await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - }) - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an - // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. - if (await exists(src)) { - await rename(src, dst).catch((err) => { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - }) - } - } - await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - }) - } finally { - this.backupRotationInFlight = false - } - } - - private rotateBackupsSync(dataFile: string): void { - if (!existsSync(dataFile)) { - return - } - try { - unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) - } catch (err) { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - } - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - if (existsSync(src)) { - try { - renameSync(src, dst) - } catch (err) { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - } - } - } - try { - copyFileSync(dataFile, backupPath(dataFile, 0)) - } catch (err) { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - } - } - - private restoreFromBackup(dataFile: string): boolean { - for (let i = 0; i < BACKUP_COUNT; i++) { - const path = backupPath(dataFile, i) - if (!existsSync(path)) { - continue - } - try { - const raw = readFileSync(path, 'utf-8') - JSON.parse(raw) - mkdirSync(dirname(dataFile), { recursive: true }) - writeFileSync(dataFile, raw, 'utf-8') - console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) - return true - } catch (err) { - console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) - } - } - return false - } - - private load(allowBackupRecovery = true): PersistedState { - // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. - const dataFile = this.dataFile - const fileExistedOnLoad = existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { - fileExists: fileExistedOnLoad - }) - - let result: PersistedState | null = null - try { - if (fileExistedOnLoad) { - const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - - // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. - if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - parsed.settings.opencodeSessionCookie, - isLegacyOpenCodeSessionCookie - ) - } - if (parsed.settings?.httpProxyUrl) { - const decryptedProxy = this.protectedSecrets.decryptWithStatus( - PROTECTED_SECRET_SLOT.httpProxyUrl, - parsed.settings.httpProxyUrl, - (value) => normalizeProxyUrl(value).ok - ) - // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL - // value must not masquerade as a configured proxy (silent DIRECT fallback) or - // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. - if ( - decryptedProxy.status === 'unavailable' || - (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) - ) { - parsed.settings.httpProxyUrl = '' - } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { - parsed.settings.httpProxyUrl = decryptedProxy.plaintext - } else { - console.warn( - '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' - ) - parsed.settings.httpProxyUrl = '' - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) - this.loadNeedsSave = true - } - } - if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - parsed.ui.browserKagiSessionLink, - (value) => normalizeKagiSessionLink(value) !== null - ) - } - parsed.sshPtyConsumerRecoveries = ( - Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] - ) - .map((record) => - normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) - ) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => { - const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) - const decrypted = this.protectedSecrets.decryptWithStatus( - slot, - record.ownerLease, - isLegacySshPtyOwnerLease - ) - const normalized = - decrypted.status === 'unavailable' || - (decrypted.status === 'failed' && !decrypted.plaintext) - ? record - : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) - if (!normalized) { - this.protectedSecrets.removeRetainedBlob(slot) - } - return normalized - }) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - - // Merge with defaults in case new fields were added - const homeDir = homedir() - const defaults = getDefaultPersistedState(homeDir) - const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( - Array.isArray(parsed.repos) ? parsed.repos : [], - parsed.settings?.worktreeVisibilityDefaults - ) - if (migratedExternalVisibility.changed) { - this.loadNeedsSave = true - } - const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) - if (migratedTerminalScrollback.needsSave) { - this.loadNeedsSave = true - } - if ( - parsed.settings && - typeof parsed.settings === 'object' && - Object.hasOwn(parsed.settings, 'enableGitHubAttribution') - ) { - this.loadNeedsSave = true - } - const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( - parsed.settings - ) - if (migratedTerminalTuiScrollSensitivity.needsSave) { - this.loadNeedsSave = true - } - const rawSourceControlAi = parsed.settings?.sourceControlAi - const rawSourceControlAiMissing = rawSourceControlAi === undefined - const rawSourceControlAiActionsMissing = - rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined - if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { - this.loadNeedsSave = true - } - const legacyCommitMessageAi = parsed.settings?.commitMessageAi - const migratedSourceControlAi = rawSourceControlAiMissing - ? sourceControlAiSettingsFromLegacy( - legacyCommitMessageAi ?? defaults.settings.commitMessageAi - ) - : mergeLegacyCommitMessageAiIntoSourceControlAi( - parsed.settings?.sourceControlAi, - legacyCommitMessageAi - ) - // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. - const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt - const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true - const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated - ? (rawOptionAsAlt ?? 'auto') - : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' - ? 'auto' - : rawOptionAsAlt - const floatingTerminalDefaultedForAllUsers = - parsed.settings?.floatingTerminalDefaultedForAllUsers === true - // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. - const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers - ? (parsed.settings?.floatingTerminalEnabled ?? true) - : true - // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). - const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) - const osc52ClipboardNoticePending = - osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || - parsed.ui?.osc52ClipboardDefaultOnNoticePending === true - if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { - this.loadNeedsSave = true - } - const floatingTerminalCwdMigrated = - parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true - // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. - const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated - ? !parsed.settings?.floatingTerminalCwd - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - : parsed.settings?.floatingTerminalCwd === undefined - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( - parsed.settings?.floatingTerminalTrustedCwds, - homeDir - ) - const migratedFloatingTerminalTrustedCwds = [ - ...normalizedFloatingTerminalTrustedCwds.trustedCwds - ] - const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd - const shouldTrustLegacyFloatingTerminalCwd = - !floatingTerminalCwdMigrated && - typeof rawLegacyFloatingTerminalCwd === 'string' && - rawLegacyFloatingTerminalCwd.trim().length > 0 && - rawLegacyFloatingTerminalCwd.trim() !== '~' - if (!floatingTerminalCwdMigrated) { - this.loadNeedsSave = true - } - if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { - const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( - rawLegacyFloatingTerminalCwd, - homeDir - ) - if ( - canonicalLegacyCwd && - !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd) - ) { - // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. - migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) - normalizedFloatingTerminalTrustedCwds.changed = true - } - } - if (normalizedFloatingTerminalTrustedCwds.changed) { - this.loadNeedsSave = true - } - const experimentalActivityDefaultedOffForAllUsers = - parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true - // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. - const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers - ? (parsed.settings?.experimentalActivity ?? false) - : false - const autoRenameBranchFromWorkDefaultedOn = - parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true - // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. - const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( - parsed.settings - ) - const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) - if ( - parsed.settings?.terminalCursorStyle !== - migratedTerminalCursorStyle.terminalCursorStyle || - parsed.settings?.terminalCursorStyleDefaultedToBlock !== true - ) { - this.loadNeedsSave = true - } - const migratedTerminalLineHeight = normalizeTerminalLineHeight( - parsed.settings?.terminalLineHeight - ) - const terminalRightClickToPasteDefaultedForPlatform = - parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true - if (!terminalRightClickToPasteDefaultedForPlatform) { - this.loadNeedsSave = true - } - if ( - parsed.settings?.terminalLineHeight !== undefined && - parsed.settings.terminalLineHeight !== migratedTerminalLineHeight - ) { - this.loadNeedsSave = true - } - const rawTaskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: parsed.settings?.visibleTaskProviders, - defaultTaskSource: parsed.settings?.defaultTaskSource - }) - const visibleTaskProvidersDefaultedForJira = - parsed.settings?.visibleTaskProvidersDefaultedForJira === true - const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira - ? rawTaskProviderSettings.visibleTaskProviders - : rawTaskProviderSettings.visibleTaskProviders.includes('jira') - ? rawTaskProviderSettings.visibleTaskProviders - : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] - const taskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: migratedVisibleTaskProviders, - defaultTaskSource: rawTaskProviderSettings.defaultTaskSource - }) - const primarySelectionDefaultedForLinux = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true - const primarySelectionDefaultedForTerminalDefaults = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true - const primarySelectionPlatformDefaultEnabled = - defaults.settings.primarySelectionMiddleClickPaste === true - const primarySelectionAlreadyDefaultedForPlatform = - primarySelectionDefaultedForTerminalDefaults || - (process.platform === 'linux' && primarySelectionDefaultedForLinux) - const migratePrimarySelectionPlatformDefault = - primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform - const stampPrimarySelectionTerminalDefaults = - primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults - if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { - this.loadNeedsSave = true - } - if (!visibleTaskProvidersDefaultedForJira) { - this.loadNeedsSave = true - } - const claudeAgentTeamsDefaultDisabledMigrated = - parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true - if (!claudeAgentTeamsDefaultDisabledMigrated) { - this.loadNeedsSave = true - } - const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( - parsed.settings?.disabledTuiAgents - ) - const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) - if ( - parsed.settings?.agentYoloDefaultsMigrated !== true || - hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || - hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) - ) { - this.loadNeedsSave = true - } - if ( - !claudeAgentTeamsDefaultDisabledMigrated && - !migratedDisabledTuiAgents.includes('claude-agent-teams') - ) { - migratedDisabledTuiAgents.push('claude-agent-teams') - } - const migratedWindowsRuntimeDefault = - parsed.settings?.localWindowsRuntimeDefault === undefined - ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime - : parsed.settings.localWindowsRuntimeDefault - if ( - parsed.settings?.localWindowsRuntimeDefault === undefined && - migratedWindowsRuntimeDefault.kind === 'wsl' - ) { - this.loadNeedsSave = true - } - // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. - const localAccountRuntimeAlreadyMigrated = - parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true - const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = - localAccountRuntimeAlreadyMigrated - ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) - : parsed.settings?.localAccountRuntime === 'wsl' - ? 'wsl' - : 'auto' - if (!localAccountRuntimeAlreadyMigrated) { - this.loadNeedsSave = true - } - if (!autoRenameBranchFromWorkDefaultedOn) { - this.loadNeedsSave = true - } - const normalizedOnboarding = normalizeLoadedOnboardingState( - parsed.onboarding, - defaults.onboarding - ) - if (!parsed.onboarding) { - this.loadNeedsSave = true - } - const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) - const loadedCompactWorktreeCards = - parsed.settings?.compactWorktreeCards ?? - parsed.settings?.experimentalCompactWorktreeCards ?? - defaults.settings.compactWorktreeCards - const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( - parsed.settings?.mobilePairingCustomAddress - ) - const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses - const mobilePairingCustomAddresses = mobilePairingCustomAddress - ? addMobilePairingCustomAddress( - normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), - mobilePairingCustomAddress - ) - : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) - if ( - parsed.settings?.mobilePairingCustomAddress !== undefined && - parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress - ) { - this.loadNeedsSave = true - } - const customAddressesMatch = - Array.isArray(rawMobilePairingCustomAddresses) && - rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && - rawMobilePairingCustomAddresses.every( - (address, index) => address === mobilePairingCustomAddresses[index] - ) - if ( - (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && - !customAddressesMatch - ) { - this.loadNeedsSave = true - } - const normalizedNotifications = normalizeNotificationSettings( - parsed.settings?.notifications - ) - // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the - // bad value stays on disk and the repair reruns on every launch. - if ( - persistedNotificationSettingsRepaired( - parsed.settings?.notifications, - normalizedNotifications - ) - ) { - this.loadNeedsSave = true - } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } - result = { - ...defaults, - ...parsed, - featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( - parsed.featureInteractionTelemetryBuckets - ), - projectGroups: normalizedProjectGroups, - repos: migratedExternalVisibility.repos, - folderWorkspaces: normalizeFolderWorkspaces( - parsed.folderWorkspaces, - normalizedProjectGroups - ), - folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( - parsed.folderWorkspaceDiffComments - ), - worktreeLineageById: parsed.worktreeLineageById ?? {}, - mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( - parsed.mobileClientTabSelectionsByDeviceId - ), - workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( - parsed.workspaceLineageByChildKey - ), - settings: { - ...defaults.settings, - // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripRetiredGlobalSettings(parsed.settings), - worktreeVisibilityDefaults: migratedExternalVisibility.defaults, - prBotAuthorOverrides: normalizePRBotAuthorOverrides( - parsed.settings?.prBotAuthorOverrides - ), - // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. - experimentalPet: - parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, - // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. - primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault - ? true - : (parsed.settings?.primarySelectionMiddleClickPaste ?? - defaults.settings.primarySelectionMiddleClickPaste), - primarySelectionMiddleClickPasteDefaultedForLinux: - primarySelectionDefaultedForLinux || - (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: - primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, - ...migratedAutoRenameBranchFromWork, - ...migratedTerminalCursorStyle, - terminalLineHeight: migratedTerminalLineHeight, - // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. - terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform - ? (parsed.settings?.terminalRightClickToPaste ?? - defaults.settings.terminalRightClickToPaste) - : parsed.settings?.terminalRightClickToPaste === false - ? false - : defaults.settings.terminalRightClickToPaste, - terminalRightClickToPasteDefaultedForPlatform: true, - ...migratedTerminalTuiScrollSensitivity.settings, - experimentalActivity: migratedExperimentalActivity, - experimentalActivityDefaultedOffForAllUsers: true, - // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. - compactWorktreeCards: loadedCompactWorktreeCards, - experimentalCompactWorktreeCards: undefined, - terminalMacOptionAsAlt: migratedOptionAsAlt, - terminalMacOptionAsAltMigrated: true, - localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, - localAccountRuntime: migratedLocalAccountRuntime, - localAccountRuntimeDefaultedToAutoForAllUsers: true, - ...migratedOsc52Clipboard, - floatingTerminalEnabled: migratedFloatingTerminalEnabled, - floatingTerminalDefaultedForAllUsers: true, - floatingTerminalCwd: migratedFloatingTerminalCwd, - floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, - floatingTerminalCwdMigratedToAppWorkspace: true, - terminalScrollbackRows: migratedTerminalScrollback.rows, - terminalQuickCommands: normalizeTerminalQuickCommands( - parsed.settings?.terminalQuickCommands - ), - terminalCustomThemes: normalizeTerminalCustomThemes( - parsed.settings?.terminalCustomThemes - ), - appIcon: normalizeAppIconId(parsed.settings?.appIcon), - mobilePairingCustomAddress, - mobilePairingCustomAddresses, - // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. - minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, - // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. - showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, - uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), - defaultTaskSource: taskProviderSettings.defaultTaskSource, - visibleTaskProviders: taskProviderSettings.visibleTaskProviders, - visibleTaskProvidersDefaultedForJira: true, - terminalShortcutPolicy: normalizeTerminalShortcutPolicy( - parsed.settings?.terminalShortcutPolicy - ), - disabledTuiAgents: migratedDisabledTuiAgents, - ...migratedAgentYoloDefaults, - claudeAgentTeamsDefaultDisabledMigrated: true, - openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { - seedDefaults: true - }), - notifications: normalizedNotifications, - sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, - // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. - commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( - migratedSourceControlAi, - parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi - ), - voice: { - ...getDefaultVoiceSettings(), - ...parsed.settings?.voice - } - }, - // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. - ui: (() => { - const rawSort = parsed.ui?.sortBy - const sort = normalizeSortBy(rawSort) - const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' - const rightSidebarOpen = - typeof parsed.ui?.rightSidebarOpen === 'boolean' - ? parsed.ui.rightSidebarOpen - : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' - ? parsed.settings.rightSidebarOpenByDefault - : defaults.ui.rightSidebarOpen - if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { - this.loadNeedsSave = true - } - const workspaceStatusesDefaultOrderMigrated = - parsed.ui?._workspaceStatusesDefaultOrderMigrated === true - // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. - const workspaceStatusesReorderedDefaultRepaired = - parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true - // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. - const workspaceStatusesDefaultWorkflowMigrated = - parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true - // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. - const workspaceStatusesDefaultVisualsMigrated = - parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true - const workspaceStatuses = normalizePersistedWorkspaceStatuses( - parsed.ui?.workspaceStatuses, - { - migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, - repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, - migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated - } - ) - if ( - !workspaceStatusesDefaultOrderMigrated || - !workspaceStatusesReorderedDefaultRepaired || - !workspaceStatusesDefaultWorkflowMigrated || - !workspaceStatusesDefaultVisualsMigrated - ) { - this.loadNeedsSave = true - } - const rawCardProps = parsed.ui?.worktreeCardProperties - const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true - const expandedCardPropsMigrated = - parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true - const jiraIssueCardPropDefaulted = - parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true - const hadExperimentOn = readDeprecatedExperimentFlag(parsed) - const deliberateUncheck = - hadExperimentOn && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsInlineAgentsMigration = - !inlineAgentsMigrated && - !deliberateUncheck && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsLegacyDefaultedCompactMigration = - loadedCompactWorktreeCards && - parsed.ui?._worktreeCardModeDefaulted === true && - isDefaultedCompactWorktreeCardProperties(rawCardProps) - const migratedCardProps = (() => { - if (!Array.isArray(rawCardProps)) { - return undefined - } - if (needsLegacyDefaultedCompactMigration) { - return getWorktreeCardModeProperties('Compact') - } - const candidate = needsInlineAgentsMigration - ? [...rawCardProps, 'inline-agents' as const] - : rawCardProps - const expandedCandidate = (() => { - if (expandedCardPropsMigrated) { - return candidate - } - const next = [...candidate] - // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. - if (candidate.includes('issue') && !candidate.includes('linear-issue')) { - next.push('linear-issue' as const) - } - if (!candidate.includes('ports')) { - next.push('ports' as const) - } - return next - })() - // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. - const jiraCandidate = - jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') - ? expandedCandidate - : [...expandedCandidate, 'jira-issue' as const] - const normalized = normalizeWorktreeCardProperties(jiraCandidate) - const changed = - normalized.length !== rawCardProps.length || - normalized.some((property, index) => property !== rawCardProps[index]) - return changed ? normalized : undefined - })() - if ( - migratedCardProps !== undefined || - !inlineAgentsMigrated || - !expandedCardPropsMigrated || - !jiraIssueCardPropDefaulted - ) { - this.loadNeedsSave = true - } - const rawExplorerView = parsed.ui?.rightSidebarExplorerView - const rightSidebarExplorerView = normalizeRightSidebarExplorerView( - rawExplorerView, - parsed.ui?.rightSidebarTab - ) - // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays - // in memory only, so a profile that never writes again redoes it on every launch. - if ( - rawExplorerView === undefined - ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView - : rawExplorerView !== rightSidebarExplorerView - ) { - this.loadNeedsSave = true - } - const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( - parsed.ui?.setupGuideSidebarDismissed, - normalizedOnboarding - ) - if ( - parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && - (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) - ) { - this.loadNeedsSave = true - } - // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. - const usagePercentageDisplayChangeNoticeDismissed = - resolveUsagePercentageDisplayChangeNoticeDismissed({ - rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, - rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, - isExistingProfile: isExistingPersistedProfile({ - repoCount: parsed.repos?.length ?? 0, - onboardingClosedAt: normalizedOnboarding.closedAt, - ui: parsed.ui - }) - }) - if ( - parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== - usagePercentageDisplayChangeNoticeDismissed - ) { - this.loadNeedsSave = true - } - return { - ...defaults.ui, - // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. - worktreeCardProperties: getWorktreeCardModeProperties( - loadedCompactWorktreeCards ? 'Compact' : 'Default' - ), - ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), - // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. - rightSidebarOpen, - rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), - // Why here and not in getPersistedUI: only the raw payload still shows the legacy - // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. - rightSidebarExplorerView, - setupGuideSidebarDismissed, - usagePercentageDisplayChangeNoticeDismissed, - setupGuideBrowserMilestoneMigrated: - typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' - ? parsed.ui.setupGuideBrowserMilestoneMigrated - : false, - setupGuideBrowserMilestoneLegacyComplete: - parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, - // Why persist rather than notify inline: the flip lands during load, before any - // window exists, and it must survive a crash before the user ever sees the notice. - osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, - sortBy: migrate ? ('smart' as const) : sort, - showDotfilesByWorktree: normalizeShowDotfilesByWorktree( - parsed.ui?.showDotfilesByWorktree - ), - workspaceStatuses, - _workspaceStatusesDefaultOrderMigrated: true, - _workspaceStatusesReorderedDefaultRepaired: true, - _workspaceStatusesDefaultWorkflowMigrated: true, - _workspaceStatusesDefaultVisualsMigrated: true, - _sortBySmartMigrated: true, - ...(migratedCardProps !== undefined - ? { worktreeCardProperties: migratedCardProps } - : {}), - // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. - _inlineAgentsDefaultedForExperiment: true, - _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true, - _jiraIssueWorktreeCardPropertyDefaulted: true - } - })(), - // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: (() => { - if (parsed.workspaceSession === undefined) { - return defaults.workspaceSession - } - const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) - if (!result.ok) { - console.error( - '[persistence] Corrupt workspace session, using defaults:', - result.error - ) - return defaults.workspaceSession - } - if (result.droppedCount > 0) { - console.warn( - '[persistence] Salvaged workspace session; dropped corrupt entries:', - workspaceSessionSalvageLogDetails(result) - ) - // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return { ...defaults.workspaceSession, ...result.value } - })(), - // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: (() => { - const { partitions, repaired } = parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ) - if (repaired) { - // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return partitions - })(), - sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), - deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) - ? parsed.deletedSshConfigAliases.filter( - (alias): alias is string => typeof alias === 'string' - ) - : [], - retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByRepo - ), - retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByNamespace - ), - sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) - .map(normalizeSshRemotePtyLease) - .filter((lease): lease is SshRemotePtyLease => lease !== null), - sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), - migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( - parsed.migrationUnsupportedPtyEntries - ), - legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( - parsed.legacyPaneKeyAliasEntries - ), - automations: Array.isArray(parsed.automations) ? parsed.automations : [], - automationRuns: (() => { - if (!Array.isArray(parsed.automationRuns)) { - return [] - } - const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) - // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. - if (runs.length !== parsed.automationRuns.length) { - this.loadNeedsSave = true - } - return runs - })(), - onboarding: normalizedOnboarding - } - } - } catch (err) { - console.error('[persistence] Failed to load primary state, trying backups:', err) - } - - // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - if (result === null && allowBackupRecovery) { - let hasBackup = false - for (let i = 0; i < BACKUP_COUNT; i++) { - if (existsSync(backupPath(dataFile, i))) { - hasBackup = true - break - } - } - if (fileExistedOnLoad || hasBackup) { - if (this.restoreFromBackup(dataFile)) { - return this.load(false) - } - console.error('[persistence] No usable state file or backup found, using defaults') - } - } - - if (result === null) { - result = getDefaultPersistedState(homedir()) - } - - const workspaceSession = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) - ) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - if (migratedScrollback.changed) { - this.loadNeedsSave = true - } - - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) - const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) - if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { - this.loadNeedsSave = true - } - - const automationContextMigration = backfillLegacyAutomationContexts({ - ...result, - repos, - ...projectHostSetupCompatibility - }) - if (automationContextMigration.changed) { - this.loadNeedsSave = true - } - result = { - ...result, - automations: automationContextMigration.state.automations, - automationRuns: automationContextMigration.state.automationRuns - } - - const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ - ...result, - repos, - ...projectHostSetupCompatibility, - workspaceSession: migratedScrollback.session - }) - if (folderScopeConnectionMigration.changed) { - this.loadNeedsSave = true - } - result = folderScopeConnectionMigration.state - - if (normalizeWorktreeLinkedItemMetadata(result)) { - this.loadNeedsSave = true - } - - if (gcStaleWorktreeMeta(result) > 0) { - this.loadNeedsSave = true - } - - const migrated = this.migrateTabSwitchKeybindings( - this.migrateTelemetry(result, fileExistedOnLoad), - fileExistedOnLoad - ) - - // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. - const legacyCache = migrated.githubCache - const hasLegacyCache = - Object.keys(legacyCache?.pr ?? {}).length > 0 || - Object.keys(legacyCache?.issue ?? {}).length > 0 - if (hasLegacyCache) { - this.loadNeedsSave = true - // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. - this.githubCacheDirty = true - } else { - migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache - } - - logPersistenceStartupMilestone('persistence-load-done', { - repos: migrated.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) - }) - return migrated - } - - // One-shot telemetry cohort migration: seeds existedBeforeTelemetryRelease, optedIn, and installId (no-op once set). - // One-shot tab-switch cohort freeze: fileExistedOnLoad tells existing vs fresh only on the first launch, so persist now. - private migrateTabSwitchKeybindings( - state: PersistedState, - fileExistedOnLoad: boolean - ): PersistedState { - const existing = state.settings?.tabSwitchKeybindingSeed - if (existing === 'pending' || existing === 'done') { - return state - } - // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. - this.loadNeedsSave = true - return { - ...state, - settings: { - ...state.settings, - // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. - tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' - } - } - } - - private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { - const existing = state.settings?.telemetry - // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. - if ( - typeof existing?.existedBeforeTelemetryRelease === 'boolean' && - typeof existing.installId === 'string' && - existing.installId.length > 0 && - (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) - ) { - return state - } - // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. - const resolvedExistedBefore = - typeof existing?.existedBeforeTelemetryRelease === 'boolean' - ? existing.existedBeforeTelemetryRelease - : fileExistedOnLoad - return { - ...state, - settings: { - ...state.settings, - telemetry: { - ...existing, - existedBeforeTelemetryRelease: resolvedExistedBefore, - // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. - optedIn: - existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null - ? existing.optedIn - : resolvedExistedBefore - ? null - : true, - installId: - typeof existing?.installId === 'string' && existing.installId.length > 0 - ? existing.installId - : randomUUID() - } - } - } - } - - // Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s. - private static SAVE_DEBOUNCE_MS = 1_000 - private static SAVE_MAX_WAIT_MS = 5_000 - - private scheduleSave(): void { - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (this.quitFlushStarted) { - return - } - this.writeGeneration += 1 - const now = Date.now() - this.firstPendingSaveAt ??= now - if (this.writeTimer) { - clearTimeout(this.writeTimer) - } - const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) - const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) - this.writeTimer = setTimeout(() => { - this.writeTimer = null - this.firstPendingSaveAt = null - void this.enqueueWrite() - }, delay) - } - - private enqueueWrite(): Promise { - const previousWrite = Promise.all([ - this.pendingWrite ?? this.staleTempCleanup, - this.pendingSnapshotFileWork ?? Promise.resolve() - ]).then(() => {}) - const write = previousWrite.then(() => this.writeToDiskAsync()) - const trackedWrite = write - .catch((err) => { - console.error('[persistence] Failed to write state:', err) - }) - .finally(() => { - if (this.pendingWrite === trackedWrite) { - this.pendingWrite = null - } - }) - this.pendingWrite = trackedWrite - return write - } - - /** Wait for any in-flight async disk write to complete. Used in tests. */ - async waitForPendingWrite(): Promise { - await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) - } - - // Why githubCache is omitted: memory-only this session (see getGithubCacheFile), so refreshes never touch the durable file. - private getDurableState(): Omit { - const { githubCache: _memoryOnly, ...durable } = this.state - return durable - } - - // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. - private buildStateToSave(): { - payload: string - stateHash: string - protectedSecretUpdates: ProtectedSecretRetentionUpdate[] - } { - // Why sentinels (not a blob/key string match): the substitution must be - // position-exact. A plain search for the ciphertext — or even for a - // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an - // agentDefaultEnv var named after a secret field, or a value equal to a - // ciphertext), which would substitute the wrong site and let two DISTINCT - // states normalize equal → a silently dropped write (data loss), reachable - // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot - // random UUID can't occur anywhere else in the serialized state (the user - // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] - const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] - let protectedStorageDegraded = false - const encryptToSentinel = (slot: string, plaintext: string): string => { - const encrypted = this.protectedSecrets.encrypt(slot, plaintext) - if (encrypted.retentionUpdate) { - protectedSecretUpdates.push(encrypted.retentionUpdate) - } - protectedStorageDegraded ||= encrypted.degraded - const { blob, hashValue = plaintext } = encrypted - // Values already identical in payload and hash need no sentinel substitution. - if (blob === plaintext && hashValue === plaintext) { - return blob - } - const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, hashValue }) - return sentinel - } - const encryptOptionalToSentinel = ( - slot: string, - plaintext: string | null | undefined - ): string | null => { - const encrypted = encryptToSentinel(slot, plaintext ?? '') - return encrypted || null - } - // Why: clone before encrypting secrets so in-memory this.state stays plaintext. - const stateToSave = { - ...this.getDurableState(), - // Why both keys unconditionally: the explicit keys always win over the spread, and - // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. - // The strip builds a new array here only; this.state records keep their notes in memory. - folderWorkspaces: (this.state.folderWorkspaces ?? []).map( - ({ diffComments: _relocated, ...rest }) => rest - ), - folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces), - sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ - ...record, - ownerLease: encryptToSentinel( - sshPtyOwnerLeaseSecretSlot(record.targetId), - record.ownerLease - ) - })), - settings: { - ...stripRetiredGlobalSettings(this.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.state.settings.opencodeSessionCookie - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.state.settings.httpProxyUrl ?? '' - ) - }, - ui: { - ...this.state.ui, - browserKagiSessionLink: encryptOptionalToSentinel( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - this.state.ui.browserKagiSessionLink - ) - } - } - // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. - // One full-state stringify; secret slots currently hold sentinels. - const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') - return { payload, stateHash, protectedSecretUpdates } - } - - // Why: async writes avoid blocking the main Electron thread on every debounced save. - private async writeToDiskAsync(): Promise { - if (this.writesFrozen) { - return - } - const gen = this.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === this.lastWrittenStateHash) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - await handle.writeFile(payload, 'utf-8') - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (this.writeGeneration !== gen) { - return - } - this.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { - throw error - } - } finally { - if (this.inFlightAsyncTmpFile === tmpFile) { - this.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && this.writeGeneration === gen) { - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - } else if (renamed) { - this.lastWrittenStateHash = null - } - if (renamed) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (this.writeGeneration !== gen) { - return - } - await this.rotateBackupsAsync(dataFile) - } - - // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. - private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { - if (this.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if (!opts.force && stateHash === this.lastWrittenStateHash) { - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - this.lastDurableWriteGeneration = Math.max( - this.lastDurableWriteGeneration, - this.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { - this.rotateBackupsSync(dataFile) - } - } - - flushOrThrow(): void { - if (this.quitFlushStarted) { - throw new Error('Cannot synchronously flush after final persistence has started') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const asyncWriteWasInFlight = this.pendingWrite !== null - // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this.writeGeneration++ - if (this.inFlightAsyncTmpFile) { - try { - unlinkSync(this.inFlightAsyncTmpFile) - this.inFlightAsyncTmpFile = null - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - void this.enqueueWrite().catch(() => {}) - throw error - } - } - } - // Why: later async flushes must remain serialized behind the invalidated writer. - this.writeToDiskSync({ - force: asyncWriteWasInFlight, - skipBackupRotation: this.backupRotationInFlight - }) - } - - flushActiveViewPreferenceOrThrow(): void { - this.activeViewPreference.flushOrThrow() - } - - getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { - return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) - } - - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } - const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this.state.codexResetCreditAttemptLedger - ? structuredClone(this.state.codexResetCreditAttemptLedger) - : undefined - this.state.codexResetCreditAttemptLedger = next - try { - this.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - this.state.codexResetCreditAttemptLedger = previous - throw error - } - } - - // ── Repos ────────────────────────────────────────────────────────── - getProfileStorageDirectory(): string { - return dirname(this.dataFile) + return dirname(this.runtime.dataFile) } - private projectHostOperations: ProjectHostPersistenceOperations | null = null - - private getProjectHostOperations(): ProjectHostPersistenceOperations { - this.projectHostOperations ??= new ProjectHostPersistenceOperations({ - state: this.state, - gitUsernameCache: this.gitUsernameCache, - hydrateRepo: (repo) => this.hydrateRepo(repo), - updateRepoBackedProjectHostSetup: (setup, repo, updates) => - this.updateRepoBackedProjectHostSetup(setup, repo, updates), - updateIndependentProjectHostSetup: (setup, updates) => - this.updateIndependentProjectHostSetup(setup, updates), - removeProjectForHost: (id, hostId) => this.removeProjectForHost(id, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostOperations - } - - getRepos(): Repo[] { - return this.getProjectHostOperations().getRepos() - } - - getProjects(): Project[] { - return this.getProjectHostOperations().getProjects() - } - - updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { - return this.getProjectHostOperations().updateProject(id, updates) - } - - getProjectHostSetups(): ProjectHostSetup[] { - return this.getProjectHostOperations().getProjectHostSetups() - } - - createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { - return this.getProjectHostOperations().createProjectHostSetup(args) - } - - updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { - return this.getProjectHostOperations().updateProjectHostSetup(args) - } - - deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { - return this.getProjectHostOperations().deleteProjectHostSetup(args) - } - - getRepoCount(): number { - return this.getProjectHostOperations().getRepoCount() - } - - getRepo(id: string): Repo | undefined { - return this.getProjectHostOperations().getRepo(id) - } - - setResolvedRepoGitUsername( - target: Pick, - username: string - ): boolean { - return this.getProjectHostOperations().setResolvedRepoGitUsername(target, username) - } - - private projectGroupOperations: ProjectGroupPersistenceOperations | null = null - - private getProjectGroupOperations(): ProjectGroupPersistenceOperations { - this.projectGroupOperations ??= new ProjectGroupPersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - }) - return this.projectGroupOperations - } - - getProjectGroups(): ProjectGroup[] { - return this.getProjectGroupOperations().getProjectGroups() - } - - createProjectGroup( - input: Parameters[0] - ): ProjectGroup { - return this.getProjectGroupOperations().createProjectGroup(input) - } - - updateProjectGroup( - groupId: string, - updates: Parameters[1] - ): ProjectGroup | null { - return this.getProjectGroupOperations().updateProjectGroup(groupId, updates) - } - - deleteProjectGroup(groupId: string): boolean { - return this.getProjectGroupOperations().deleteProjectGroup(groupId) - } - - private folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null - - private getFolderWorkspaceOperations(): FolderWorkspacePersistenceOperations { - this.folderWorkspaceOperations ??= new FolderWorkspacePersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.folderWorkspaceOperations - } - - getFolderWorkspaces(): FolderWorkspace[] { - return this.getFolderWorkspaceOperations().getFolderWorkspaces() - } - - getFolderWorkspace(id: string): FolderWorkspace | undefined { - return this.getFolderWorkspaceOperations().getFolderWorkspace(id) - } - - createFolderWorkspace( - input: Parameters[0] - ): FolderWorkspace { - return this.getFolderWorkspaceOperations().createFolderWorkspace(input) - } - - updateFolderWorkspace( - id: string, - updates: Parameters[1] - ): FolderWorkspace | null { - return this.getFolderWorkspaceOperations().updateFolderWorkspace(id, updates) - } - - removeFolderWorkspace(id: string): boolean { - return this.getFolderWorkspaceOperations().removeFolderWorkspace(id) - } - - moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { - return this.getFolderWorkspaceOperations().moveProjectToGroup(repoId, groupId, order) - } - - private repoOrderOperations: RepoOrderPersistenceOperations | null = null - - private getRepoOrderOperations(): RepoOrderPersistenceOperations { - this.repoOrderOperations ??= new RepoOrderPersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - }) - return this.repoOrderOperations - } - - addRepo(repo: Repo): void { - this.getRepoOrderOperations().addRepo(repo) - } - - reorderRepos(orderedIds: string[]): boolean { - return this.getRepoOrderOperations().reorderRepos(orderedIds) - } - - reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { - return this.getRepoOrderOperations().reorderReposForHost(orderedIds, hostId) - } - - removeProject(id: string): void { - this.state.repos = this.state.repos.filter((r) => r.id !== id) - this.syncProjectHostSetupCompatibilityState() - // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - this.pruneWorktreeStateForRepo(id, null) - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - this.scheduleSave() - } - - // Why: the same repo id can exist on multiple execution hosts; remove only this host's row and metadata, never another host's. - removeProjectForHost(id: string, hostId: ExecutionHostId): void { - this.state.repos = this.state.repos.filter( - (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) - ) - const idStillPresent = this.state.repos.some((r) => r.id === id) - // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. - if (!idStillPresent) { - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - } - this.syncProjectHostSetupCompatibilityState() - // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). - this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) - if (!idStillPresent) { - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { - const session = this.state.workspaceSessionsByHostId?.[hostId] - if (session) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: removeRepoFromWorkspaceSession(session, id) - } - } - } - this.scheduleSave() - } - - // Prune worktree meta/lineage for a repo id; hostId null prunes all entries, else only that host's (missing meta.hostId = local). - private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { - pruneWorktreeStateForRepoOperation(this.state, id, hostId, (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - ) - } - - private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { - for (const [clientNavigationId, selectionsByWorktree] of Object.entries( - this.state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - for (const worktreeId of Object.keys(selectionsByWorktree)) { - if (matchesWorktreeId(worktreeId)) { - delete selectionsByWorktree[worktreeId] - } - } - if (Object.keys(selectionsByWorktree).length === 0) { - delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] - } - } - } - - private repoUpdateOperations: RepoUpdatePersistenceOperations | null = null - - private getRepoUpdateOperations(): RepoUpdatePersistenceOperations { - this.repoUpdateOperations ??= new RepoUpdatePersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave(), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.repoUpdateOperations - } - - updateRepo( - id: string, - updates: Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'gitRemoteIdentity' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'executionHostId' - | 'symlinkPaths' - | 'issueSourcePreference' - | 'forkSyncMode' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - | 'projectHostSetupMethod' - > - > & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null - }, - hostId?: ExecutionHostId - ): Repo | null { - return this.getRepoUpdateOperations().updateRepo(id, updates, hostId) - } - - private syncProjectHostSetupCompatibilityState(): void { - const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) - this.state.projects = compatibilityState.projects - this.state.projectHostSetups = compatibilityState.projectHostSetups - } - - private projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null - - private getProjectHostSetupOperations(): ProjectHostSetupPersistenceOperations { - this.projectHostSetupOperations ??= new ProjectHostSetupPersistenceOperations({ - state: this.state, - updateRepo: (id, updates, hostId) => this.updateRepo(id, updates, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostSetupOperations - } - - private updateRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - repo: Repo, - updates: ProjectHostSetupUpdateArgs['updates'] - ): { setup: ProjectHostSetup; repo: Repo } | null { - return this.getProjectHostSetupOperations().updateRepoBackedProjectHostSetup( - setup, - repo, - updates - ) - } - - private updateIndependentProjectHostSetup( - setup: ProjectHostSetup, - updates: ProjectHostSetupUpdateArgs['updates'] - ): ProjectHostSetup { - return this.getProjectHostSetupOperations().updateIndependentProjectHostSetup(setup, updates) - } - - private hydrateRepo(repo: Repo): Repo { - return hydrateRepoOperation(repo, this.gitUsernameCache) - } - - // ── Sparse Presets ───────────────────────────────────────────────── - - // ── Mobile client tab selections ────────────────────────────────── - - getMobileClientTabSelections(): PersistedMobileClientTabSelections { - return this.state.mobileClientTabSelectionsByDeviceId ?? {} - } - - setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { - this.state.mobileClientTabSelectionsByDeviceId = next - this.scheduleSave() - } - - getSparsePresets(repoId: string): SparsePreset[] { - return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => - left.name.localeCompare(right.name) - ) - } - - saveSparsePreset(preset: SparsePreset): SparsePreset { - const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] - const index = existing.findIndex((entry) => entry.id === preset.id) - this.state.sparsePresetsByRepo[preset.repoId] = - index === -1 - ? [...existing, preset] - : existing.map((entry, i) => (i === index ? preset : entry)) - this.scheduleSave() - return preset - } - - removeSparsePreset(repoId: string, presetId: string): void { - const existing = this.state.sparsePresetsByRepo[repoId] ?? [] - this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) - this.scheduleSave() - } - - // ── Automations ─────────────────────────────────────────────────── - - private getAutomationDefinitionOperations(): AutomationDefinitionOperations { - return { - state: this.state, - flush: () => this.flush(), - recordCreated: () => this.recordFeatureInteraction('automation-created') - } - } - - private getAutomationRunOperations(): AutomationRunOperations { - return { - state: this.state, - flush: () => this.flush(), - recordManualRun: () => this.recordFeatureInteraction('automation-run'), - getWorkspaceDisplayName: (workspaceId) => - this.getAutomationRunWorkspaceDisplayName(workspaceId) - } - } - - listAutomations(): Automation[] { - return listAutomationsOperation(this.state) - } - - listAutomationRuns(automationId?: string): AutomationRun[] { - return listAutomationRunsOperation(this.state, automationId) - } - - createAutomation(input: AutomationCreateInput): Automation { - return createAutomationOperation(this.getAutomationDefinitionOperations(), input) - } - - updateAutomation(id: string, updates: AutomationUpdateInput): Automation { - return updateAutomationOperation(this.getAutomationDefinitionOperations(), id, updates) - } - - deleteAutomation(id: string): void { - deleteAutomationOperation(this.getAutomationDefinitionOperations(), id) - } - - createAutomationRun( - automation: Automation, - scheduledFor: number, - trigger: AutomationRunTrigger = 'scheduled' - ): AutomationRun { - return createAutomationRunOperation( - this.getAutomationRunOperations(), - automation, - scheduledFor, - trigger - ) - } - - updateAutomationRun(result: AutomationDispatchResult): AutomationRun { - return updateAutomationRunOperation(this.getAutomationRunOperations(), result) - } - - snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { - return snapshotAutomationRunWorkspaceDisplayNameOperation( - this.getAutomationRunOperations(), - workspaceId, - displayName - ) - } - - private getAutomationRunWorkspaceDisplayName( - workspaceId: string | null | undefined - ): string | null { - if (!workspaceId) { - return null - } - return normalizeAutomationRunWorkspaceDisplayName( - this.state.worktreeMeta[workspaceId]?.displayName ?? - getWorktreePathBasenameFromId(workspaceId) - ) - } - - advanceAutomationNextRun(id: string, now = Date.now()): Automation { - return advanceAutomationNextRunOperation(this.state, () => this.flush(), id, now) - } - - getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { - return getLatestAutomationOccurrenceOperation(automation, now) - } - - // ── Worktree Meta ────────────────────────────────────────────────── - - getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { - return this.state.worktreeMeta[worktreeId] - } - - getAllWorktreeMeta(): Record { - return this.state.worktreeMeta - } - - setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { - const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() - const updated = { ...existing, ...meta } - updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updated.linkedTaskSourceContext - ) - updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - updated.linkedWorkItem, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null - if (!updated.instanceId) { - updated.instanceId = randomUUID() - } - this.state.worktreeMeta[worktreeId] = updated - this.scheduleSave() - return updated - } - - removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { - // A host-qualified removal names the owner; the persisted host is the fallback. - const persistedOwner = this.state.worktreeMeta[worktreeId]?.hostId - const owner = hostId ?? persistedOwner - const preservesDifferentPersistedOwner = Boolean( - hostId && persistedOwner && persistedOwner !== hostId - ) - const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) - const preservesSameIdSessionOwner = Boolean( - preservesDifferentPersistedOwner || - (owner && - hasWorktreeRemovalRepoOwnerOnOtherHost( - this, - getRepoIdFromWorktreeId(worktreeId), - ownerPartition - )) - ) - // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. - const partitions = new Set( - workspaceSessionPartitionIdsForHost(owner).filter( - (partition) => - this.hasPersistedWorkspaceSession(partition) && - // The local partition can be a remote spill surface or a same-id owner. - // Preserve it whenever another owner may still use the bare id. - (!preservesSameIdSessionOwner || partition === ownerPartition) - ) - ) - // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill - // partition that never held this worktree has no claim on the repo at all. - const fencedPartitions = new Set( - [...partitions].filter( - (partition) => - this.partitionOwnsWorktreeTabs(worktreeId, partition) || - (partition === ownerPartition && - !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) - ) - ) - if (!preservesDifferentPersistedOwner) { - delete this.state.worktreeMeta[worktreeId] - delete this.state.worktreeLineageById[worktreeId] - delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] - } - for (const partition of partitions) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { - advanceTerminalTopologyRevision: fencedPartitions.has(partition) - }) - } - this.scheduleSave() - } - - getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { - return this.state.worktreeLineageById[worktreeId] - } - - getAllWorktreeLineage(): Record { - return this.state.worktreeLineageById - } - - setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { - this.state.worktreeLineageById[worktreeId] = lineage - this.scheduleSave() - return lineage - } - - removeWorktreeLineage(worktreeId: string): void { - delete this.state.worktreeLineageById[worktreeId] - this.scheduleSave() - } - - /** - * Re-key every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId` after the worktree folder (and its - * `${repoId}::${path}` id) was renamed on disk, so a refresh re-binds state instead of orphaning it. Records the old id on - * the new meta's `priorWorktreeIds` so session GC/hydration still recognizes PTY sessions minted under it. No-op when ids match. - * Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts. - */ - migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { - if (migrateWorktreeIdentityOperation(this.state, oldWorktreeId, newWorktreeId)) { - this.scheduleSave() - } - } - - getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { - return this.state.workspaceLineageByChildKey[childWorkspaceKey] - } - - getAllWorkspaceLineage(): Record { - return this.state.workspaceLineageByChildKey - } - - setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { - this.state.workspaceLineageByChildKey[lineage.childWorkspaceKey] = lineage - this.scheduleSave() - return lineage - } - - removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { - delete this.state.workspaceLineageByChildKey[childWorkspaceKey] - this.scheduleSave() - } - - private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { - const parentKey = folderWorkspaceKey(folderWorkspaceId) - for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - if (lineage.parentWorkspaceKey === parentKey) { - delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] - } - } - } - - // ── Settings ─────────────────────────────────────────────────────── - - getSettings(): GlobalSettings { - return this.state.settings - } - - onSettingsChanged( - listener: ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - ): () => void { - this.settingsChangeListeners.add(listener) - return () => { - this.settingsChangeListeners.delete(listener) - } - } - - private notifySettingsChanged( - updates: Partial, - originWebContentsId?: number - ): void { - for (const listener of this.settingsChangeListeners) { - listener(updates, this.state.settings, originWebContentsId) - } - } - - // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. - onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { - this.uiChangeListeners.add(listener) - return () => { - this.uiChangeListeners.delete(listener) - } - } - - private notifyUIChanged(): void { - if (this.uiChangeListeners.size === 0) { - return - } - const ui = this.getUI() - for (const listener of this.uiChangeListeners) { - listener(ui) - } - } - - private getSettingsMutationOperations(): SettingsMutationOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - scheduleSave: () => this.scheduleSave(), - notifySettingsChanged: (updates, originWebContentsId) => - this.notifySettingsChanged(updates, originWebContentsId) - } - } - - updateSettings( - updates: Partial, - options: { notifyListeners?: boolean; originWebContentsId?: number } = {} - ): GlobalSettings { - return updateSettingsOperation(this.getSettingsMutationOperations(), updates, options) - } - - // ── UI State ─────────────────────────────────────────────────────── - - private getUIUpdateOperations(): UIUpdateOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - setActiveView: (activeView) => this.activeViewPreference.set(activeView), - getUI: () => this.getUI(), - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged() - } - } - - getUI(): PersistedState['ui'] { - return getPersistedUI(this.state, this.activeViewPreference.get()) - } - - updateUI(updates: Partial): void { - updatePersistedUI(this.getUIUpdateOperations(), updates) - } - - private getFeatureInteractionOperations(): FeatureInteractionOperations { - return { - state: this.state, - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged(), - getUI: () => this.getUI() - } - } - - recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { - return recordFeatureInteractionOperation(this.getFeatureInteractionOperations(), id) - } - - // ── Onboarding ──────────────────────────────────────────────────── - - getOnboarding(): PersistedState['onboarding'] { - const defaults = getDefaultOnboardingState() - return { - ...defaults, - ...this.state.onboarding, - checklist: { - ...defaults.checklist, - ...this.state.onboarding?.checklist - } - } - } - - updateOnboarding( - updates: Partial> & { - checklist?: Partial - } - ): PersistedState['onboarding'] { - const current = this.getOnboarding() - this.state.onboarding = { - ...current, - ...updates, - checklist: { - ...current.checklist, - ...updates.checklist - } - } - this.scheduleSave() - return this.getOnboarding() - } - - // ── GitHub Cache ────────────────────────────────────────────────── - - getGitHubCache(): PersistedState['githubCache'] { - return this.state.githubCache - } - - setGitHubCache(cache: PersistedState['githubCache']): void { - // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. - this.state.githubCache = cache - this.githubCacheDirty = true - this.githubCacheGeneration += 1 - } - - // ── Workspace Session ───────────────────────────────────────────── - - /** Resolve an execution host argument to a canonical id; unknown/empty falls back to 'local' for legacy callers. */ - private resolveHostId(hostId?: string | null): ExecutionHostId { - return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - } - - getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - return this.state.workspaceSession ?? getDefaultWorkspaceSession() - } - return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() - } - - /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ - private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { - return ( - hostId === LOCAL_EXECUTION_HOST_ID || - this.state.workspaceSessionsByHostId?.[hostId] !== undefined - ) - } - - getWorkspaceSessionHostIds(): ExecutionHostId[] { - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { - const hostId = normalizeExecutionHostId(key) - if (hostId) { - hostIds.add(hostId) - } - } - return [...hostIds] - } - - readTerminalScrollbackSnapshot(ref: string): string | null { - return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) - } - - /** Resolve the worktree a terminal tab belongs to; more reliable than agent-echoed hook fields. */ - getWorktreeIdForTab(tabId: string): string | undefined { - return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) - } - - setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - removeWorkspaceSessionStateForWorktree( - worktreeId: string, - hostId?: ExecutionHostId | null, - options: { advanceTerminalTopologyRevision?: boolean } = {} - ): void { - for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) - } - } - - private removeWorkspaceSessionOwnerInPartition( - worktreeId: string, - resolved: ExecutionHostId, - options: { advanceTerminalTopologyRevision?: boolean } - ): void { - if (!this.hasPersistedWorkspaceSession(resolved)) { - return - } - const current = this.getWorkspaceSession(resolved) - const session = removeWorkspaceSessionOwner(current, worktreeId, { - advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true - }) - if (!session) { - return - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = session - } else { - // Host scoping matters because identical repo/path ids may exist on two servers. - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: session - } - } - this.scheduleSave() - } - - /** Whether a partition still holds terminal membership for `worktreeId`. */ - private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined - } - - /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ - private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} - return Object.entries(tabsByWorktree).some( - ([id, tabs]) => - id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 - ) - } - - stageWorkspaceSessionBeforeUnload( - session: PersistedState['workspaceSession'], - hostId?: string | null - ): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session, true) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ - private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { - // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. - session = sanitizeWorkspaceSessionTerminalRetirements( - session, - this.state.workspaceSessionsByHostId?.[hostId] - ) - const pruned = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: pruned - } - this.scheduleSave() - } - - private setLocalWorkspaceSession( - session: PersistedState['workspaceSession'], - deferSnapshotFiles = false - ): void { - const prior = this.state.workspaceSession - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) - session = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - - // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. - const normalized = normalizeWorkspaceSessionPaneIdentities( - session, - prior?.terminalLayoutsByTabId - ) - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( - this.state.ui?.acknowledgedAgentsByPaneKey, - normalized.leafIdByInputLeafIdByTabId - ) - if (remappedAcknowledgements.changed) { - this.state.ui = { - ...this.state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements - } - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - session = normalized.session - const remapsByHostId = new Map([ - [LOCAL_EXECUTION_HOST_ID, normalized] - ]) - const remappedLeases = remapSshRemotePtyLeaseLeafIds( - this.state.sshRemotePtyLeases ?? [], - remapsByHostId, - new Set(this.getWorkspaceSessionHostIds()) - ) - if (remappedLeases.changed) { - this.state.sshRemotePtyLeases = remappedLeases.leases - } - if (session && prior) { - const priorTabs = prior.tabsByWorktree ?? {} - const nextTabs = session.tabsByWorktree ?? {} - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - for (const [worktreeId, tabs] of Object.entries(nextTabs)) { - const priorList = priorTabs[worktreeId] - if (!priorList) { - continue - } - for (const tab of tabs) { - if (tab.ptyId) { - continue - } - const priorTab = priorList.find((t) => t.id === tab.id) - if ( - priorTab?.ptyId && - this.isRestorablePtyBinding({ - ptyId: priorTab.ptyId, - worktreeId, - targetId: this.getConnectionIdForWorktree(worktreeId), - tabId: tab.id - }) - ) { - tab.ptyId = priorTab.ptyId - } - } - } - const priorLayouts = prior.terminalLayoutsByTabId ?? {} - const nextLayouts = session.terminalLayoutsByTabId ?? {} - for (const [tabId, layout] of Object.entries(nextLayouts)) { - const priorLayout = priorLayouts[tabId] - if (!priorLayout?.ptyIdsByLeafId) { - continue - } - const incoming = layout.ptyIdsByLeafId ?? {} - const incomingHasAnyBinding = Object.keys(incoming).length > 0 - const liveLeafIds = this.getTerminalLayoutLeafIds(layout.root) - const worktreeId = worktreeIdByTabId.get(tabId) - const targetId = worktreeId ? this.getConnectionIdForWorktree(worktreeId) : null - const restorableBindings = Object.fromEntries( - Object.entries(priorLayout.ptyIdsByLeafId).filter( - ([leafId, ptyId]) => - liveLeafIds.has(leafId) && - incoming[leafId] === undefined && - // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. - (incomingHasAnyBinding - ? this.hasRestorableSshRemotePtyLease({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - }) - : this.isRestorablePtyBinding({ ptyId, targetId, worktreeId, tabId, leafId })) - ) - ) - if (Object.keys(restorableBindings).length > 0) { - layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } - // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. - const buffersByLeafId = preserveMissingLeafRecordEntries( - priorLayout.buffersByLeafId, - layout.buffersByLeafId, - liveLeafIds - ) - const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( - priorLayout.scrollbackRefsByLeafId, - layout.scrollbackRefsByLeafId, - liveLeafIds - ) - const titlesByLeafId = preserveMissingLeafRecordEntries( - priorLayout.titlesByLeafId, - layout.titlesByLeafId, - liveLeafIds - ) - if (buffersByLeafId) { - layout.buffersByLeafId = buffersByLeafId - } - if (scrollbackRefsByLeafId) { - layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId - } - if (titlesByLeafId) { - layout.titlesByLeafId = titlesByLeafId - } - } - } - } - session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - if (!deferSnapshotFiles) { - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - session, - this.terminalScrollbackSnapshotStorage - ) - session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots( - prior, - session, - this.terminalScrollbackSnapshotStorage - ) - } - this.state.workspaceSession = session - if (deferSnapshotFiles) { - this.enqueueTerminalScrollbackSnapshotWork(prior, session) - } - this.scheduleSave() - } - - private enqueueTerminalScrollbackSnapshotWork( - prior: WorkspaceSessionState | undefined, - staged: WorkspaceSessionState - ): void { - const previous = this.pendingSnapshotFileWork ?? Promise.resolve() - const work = previous - .then(async () => { - if (this.state.workspaceSession !== staged) { - if (this.state.workspaceSession) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - this.state.workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - } - return - } - const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( - staged, - this.terminalScrollbackSnapshotStorage - ) - const current = - this.state.workspaceSession === staged ? migrated : this.state.workspaceSession - if (this.state.workspaceSession === staged) { - this.state.workspaceSession = migrated - } else if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - migrated, - current, - this.terminalScrollbackSnapshotStorage - ) - } - if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - current, - this.terminalScrollbackSnapshotStorage - ) - } - }) - .catch((error) => { - console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) - }) - .finally(() => { - if (this.pendingSnapshotFileWork === work) { - this.pendingSnapshotFileWork = null - } - }) - this.pendingSnapshotFileWork = work - } - - patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. - let next: WorkspaceSessionState = { - ...this.getWorkspaceSession(resolved), - ...patch - } - if (workspaceSessionPatchNeedsFullNormalization(patch)) { - this.setWorkspaceSession(next, resolved) - return - } - if (Object.hasOwn(patch, 'browserUrlHistory')) { - next = pruneWorkspaceSessionBrowserHistory(next) - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = next - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: next - } - } - this.scheduleSave() - } - - private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { - const leafIds = new Set() - const visit = (node: TerminalPaneLayoutNode | null): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - if (isTerminalLeafId(node.leafId)) { - leafIds.add(node.leafId) - } - return - } - visit(node.first) - visit(node.second) - } - visit(root) - return leafIds - } - - private isRestorablePtyBinding(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - const leases = this.state.sshRemotePtyLeases?.filter((entry) => - this.sshRemotePtyLeaseMatchesBinding(entry, binding) - ) - return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') - } - - private getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { - try { - return toRelaySshPtyId(targetId, ptyId) - } catch { - return ptyId - } - } - - private getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { - return toRelaySshPtyId(targetId, ptyId) - } - - private sshRemotePtyLeaseMatchesBinding( - lease: SshRemotePtyLease, - binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - } - ): boolean { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) - if (lease.ptyId !== bindingPtyId) { - return false - } - // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. - return ( - (binding.targetId === undefined || - binding.targetId === null || - lease.targetId === binding.targetId) && - (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && - (binding.tabId === undefined || lease.tabId === binding.tabId) && - (binding.leafId === undefined || lease.leafId === binding.leafId) - ) - } - - private hasRestorableSshRemotePtyLease(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - return ( - this.state.sshRemotePtyLeases?.some( - (lease) => - this.sshRemotePtyLeaseMatchesBinding(lease, binding) && - lease.state !== 'terminated' && - lease.state !== 'expired' - ) ?? false - ) - } - - private getConnectionIdForWorktree(worktreeId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return this.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null - } - - // Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). - persistPtyBinding( - args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - ownerIdentity?: TerminalOwnerIdentity - startupCwd?: string - expectedBinding?: { - ptyId: string - incarnationId?: string - /** Present (including null) to compare the persisted owner tuple. */ - ownerIdentity?: TerminalOwnerIdentity | null - } - expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean - }, - hostId?: string | null - ): boolean { - const resolvedHostId = this.resolveHostId(hostId) - const session = this.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - if (args.expectedSourceBinding) { - const expected = args.expectedSourceBinding - if (expected.tabId !== args.tabId) { - return false - } - const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId - ) - const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] - const sourcePaneKey = `${expected.tabId}:${expected.leafId}` - if ( - !sourceTab || - sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || - !layoutContainsLeafId(sourceLayout.root, expected.leafId) || - (expected.incarnationId !== undefined && - session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) - ) { - return false - } - } - if (args.expectedBinding) { - const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId - ) - const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] - const expectedOwnerMatches = - !('ownerIdentity' in args.expectedBinding) || - (args.expectedBinding.ownerIdentity === null - ? session.terminalPtyOwnersByPaneKey?.[paneKey] === undefined - : sameTerminalOwnerIdentity( - session.terminalPtyOwnersByPaneKey?.[paneKey], - args.expectedBinding.ownerIdentity - )) - if ( - !tab || - boundPtyId !== args.expectedBinding.ptyId || - session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== - args.expectedBinding.incarnationId || - !expectedOwnerMatches - ) { - return false - } - } - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: session - } - } - const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const reconciledIncarnation = - args.expectedBinding !== undefined && - args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - const restoreSession = (): void => { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = sessionBeforeBinding - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - if (args.ownerIdentity) { - // Store the complete tuple atomically with the pane binding. A partial - // owner record is worse than legacy/unknown because it invites inference. - session.terminalPtyOwnersByPaneKey = { - ...session.terminalPtyOwnersByPaneKey, - [paneKey]: { ...args.ownerIdentity } - } - } else if (args.incarnationId) { - // A new authenticated session without owner metadata is legacy/unknown; - // never let an older owner tuple follow the rebinding. - session.terminalPtyOwnersByPaneKey = { ...session.terminalPtyOwnersByPaneKey } - delete session.terminalPtyOwnersByPaneKey[paneKey] - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = args.ptyId - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null - } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } - } - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - - // ── SSH Targets ──────────────────────────────────────────────────── - - private getSshTargetStateOperations(): SshTargetStateOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - scheduleSave: () => this.scheduleSave(), - flush: () => this.flush() - } - } - - getSshTargets(): SshTarget[] { - return getSshTargetsOperation(this.state) - } - - getSshTarget(id: string): SshTarget | undefined { - return getSshTargetOperation(this.state, id) - } - - addSshTarget(target: SshTarget): void { - addSshTargetOperation(this.getSshTargetStateOperations(), target) - } - - updateSshTarget(id: string, updates: Partial>): SshTarget | null { - return updateSshTargetOperation(this.getSshTargetStateOperations(), id, updates) - } - - removeSshTarget(id: string): void { - removeSshTargetOperation(this.getSshTargetStateOperations(), id) - } - - // ── Live Claude PTY sessions ─────────────────────────────────────── - - getClaudeLivePtySessionIds(): string[] { - return getClaudeLivePtySessionIdsOperation(this.state) - } - - addClaudeLivePtySessionId(sessionId: string): void { - addClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - removeClaudeLivePtySessionId(sessionId: string): void { - removeClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByRepo?.[repoId] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByNamespace?.[namespaceKey] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - addRetiredWorktreeName(repoId: string, name: string): void { - const normalized = normalizeRetirableGeneratedName(name) - if (!repoId || !normalized) { - return - } - this.applyRetiredWorktreeNames(repoId, [normalized]) - } - - mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - if (!repoId) { - return false - } - const incoming = new Set() - for (const name of names) { - const normalized = normalizeRetirableGeneratedName(name) - if (normalized) { - incoming.add(normalized) - } - } - return incoming.size > 0 && this.applyRetiredWorktreeNames(repoId, incoming) - } - - mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { - if (!namespaceKey) { - return false - } - const normalized = new Set() - for (const name of names) { - const candidate = normalizeRetirableGeneratedName(name) - if (candidate) { - normalized.add(candidate) - } - } - const next = addRetiredNames( - this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), - normalized - ) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByNamespace ??= {} - recordRetirementNamespaceRegistry( - this.state.retiredWorktreeNamesByNamespace, - namespaceKey, - next - ) - this.scheduleSave() - return true - } - - private applyRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - const next = addRetiredNames(this.getRetiredWorktreeNameRegistry(repoId), names) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByRepo ??= {} - this.state.retiredWorktreeNamesByRepo[repoId] = next - this.scheduleSave() - return true - } - - getDeletedSshConfigAliases(): string[] { - return getDeletedSshConfigAliasesOperation(this.state) - } - - addDeletedSshConfigAlias(alias: string): void { - addDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - removeDeletedSshConfigAlias(alias: string): void { - removeDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - clearDeletedSshConfigAliases(): void { - clearDeletedSshConfigAliasesOperation(this.getSshTargetStateOperations()) - } - - getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { - return getRemovedSshTargetTombstonesOperation(this.state) - } - - addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { - addRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), tombstone) - } - - removeRemovedSshTargetTombstone(oldTargetId: string): void { - removeRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), oldTargetId) - } - - /** - * Re-point every repo and worktree meta pinned to a removed SSH target id onto - * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. - */ - reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { - const operations: SshTargetReassignmentOperations = { - state: this.state, - protectedSecrets: this.protectedSecrets, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - } - return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) - } - - // ── SSH PTY Consumer Recovery ────────────────────────────────────── - - private getSshPtyConsumerRecoveryOperations(): SshPtyConsumerRecoveryOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { - return getSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), targetId) - } - - async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { - await upsertSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), record) - } - - async removeSshPtyConsumerRecovery(targetId: string): Promise { - await removeSshPtyConsumerRecoveryOperation( - this.getSshPtyConsumerRecoveryOperations(), - targetId - ) - } - - // ── SSH Remote PTY Leases ────────────────────────────────────────── - - private getSshPtyBindingCleanupOperations(): SshPtyBindingCleanupOperations { - return { - state: this.state, - toComparablePtyId: (targetId, ptyId) => - this.getRelayPtyIdForSshLeaseComparison(targetId, ptyId), - scheduleSave: () => this.scheduleSave() - } - } - - private getSshPtyLeaseOperations(): SshPtyLeaseOperations { - return { - state: this.state, - toStoredPtyId: (targetId, ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId), - clearBindingsForTarget: (targetId) => - clearSshRemotePtyBindingsForTargetOperation( - this.getSshPtyBindingCleanupOperations(), - targetId - ), - clearBindingsForLeases: (targetId, leases) => - clearSshRemotePtyBindingsForLeasesOperation( - this.getSshPtyBindingCleanupOperations(), - targetId, - leases - ), - flush: () => this.flush(), - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { - return getSshRemotePtyLeasesOperation(this.state, targetId) - } - - upsertSshRemotePtyLease( - lease: Omit & - Partial> - ): void { - upsertSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), lease) - } - - markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - // Why no write of its own: the committed quit path calls this immediately before the final store - // flush, and that flush is what persists it. A durable write here would race the flush and be - // rejected the moment it latches, which is exactly how an attached lease used to survive quit. - markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesForShutdownOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAsync( - targetId: string, - state: SshRemotePtyLease['state'] - ): Promise { - await markSshRemotePtyLeasesAsyncOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAttachedAsync( - targetId: string, - ptyIds: readonly string[] - ): Promise { - await markSshRemotePtyLeasesAttachedAsyncOperation( - this.getSshPtyLeaseOperations(), - targetId, - ptyIds - ) - } - - markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId, state) - } - - removeSshRemotePtyLease(targetId: string, ptyId: string): void { - removeSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId) - } - - removeSshRemotePtyLeases(targetId: string): void { - removeSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId) - } - - // ── Flush (for shutdown) ─────────────────────────────────────────── - - flush(): void { - if (this.quitFlushStarted) { - return - } - try { - this.flushOrThrow() - } catch (err) { - console.error('[persistence] Failed to flush state:', err) - } - try { - this.flushActiveViewPreferenceOrThrow() - } catch (err) { - console.error('[active-view] Failed to flush preference:', err) - } - this.writeGithubCacheSnapshotSync() - } - - /** - * Async twin of flush() for the quit path. - * - * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron - * main thread. On a stalled network profile mount that syscall is uninterruptible, so the - * app stops repainting and Force Quit stops working — and no main-thread deadline can - * bound it, because the deadline's own timer is stuck behind the same block. - * - * Never throws — it joins the quit teardown barrier, where a rejection is noise. - */ - flushAsync(): Promise { - if (this.quitFlushPromise) { - return this.quitFlushPromise - } - this.quitFlushStarted = true - this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) - return this.quitFlushPromise - } - - flushPendingAsync(): Promise { - // Best-effort callers must not livelock while the live app keeps mutating state. - return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) - } - - flushPendingOrThrowAsync( - options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} - ): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - return Promise.reject(new Error('Cannot flush while persistence is finalized')) - } - return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) - } - - // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are - // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. - private async flushDurableStateOrThrowAsync(): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - throw new Error('Cannot flush while persistence is finalized') - } - for (;;) { - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - await this.enqueueWrite() - if (generation === this.writeGeneration) { - break - } - } - } - - private async flushCurrentStateAsync( - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false - ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - try { - await this.enqueueWrite() - } catch (error) { - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - throw error - } - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - this.lastDurableWriteGeneration >= requiredDurableGeneration - ) { - break - } - continue - } - if (generation === this.writeGeneration) { - break - } - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private async writeGithubCacheSnapshotAsync( - drainToStableGeneration = true, - signal?: AbortSignal - ): Promise { - if (!this.githubCacheDirty) { - return - } - const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup - const nextWrite = previousWrite - .then(async () => { - while (this.githubCacheDirty) { - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - const generation = this.githubCacheGeneration - const cacheFile = getGithubCacheFile(this.dataFile) - const tmpFile = durableWriteTempPath(cacheFile) - let renamed = false - try { - await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - if (generation === this.githubCacheGeneration) { - await rename(tmpFile, cacheFile) - renamed = true - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - if (!drainToStableGeneration) { - break - } - } - }) - .catch((err) => { - console.warn('[persistence] Failed to write github cache snapshot:', err) - }) - .finally(() => { - if (this.pendingGithubCacheWrite === nextWrite) { - this.pendingGithubCacheWrite = null - } - }) - this.pendingGithubCacheWrite = nextWrite - await nextWrite - } - - // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. freezeWrites(): void { - this.writesFrozen = true - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private writeGithubCacheSnapshotSync(): void { - if (!this.githubCacheDirty) { - return - } - if (this.pendingGithubCacheWrite) { - void this.writeGithubCacheSnapshotAsync() - return - } - const cacheFile = getGithubCacheFile(this.dataFile) - const generation = this.githubCacheGeneration - const tmpFile = durableWriteTempPath(cacheFile) - try { - writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - renameSync(tmpFile, cacheFile) - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } catch (err) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup. - } - console.warn('[persistence] Failed to write github cache snapshot:', err) + this.runtime.writesFrozen = true + if (this.runtime.writeTimer) { + clearTimeout(this.runtime.writeTimer) + this.runtime.writeTimer = null } } } -function getDefaultWorktreeMeta(): WorktreeMeta { - return { - instanceId: randomUUID(), - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null, - linkedWorkItem: null, - linkedTaskSourceContext: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: Date.now(), - lastActivityAt: 0, - workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID +// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below +export interface Store + extends + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations {} + +for (const OperationClass of STORE_DOMAIN_OPERATION_CLASSES) { + const descriptors = Object.getOwnPropertyDescriptors(OperationClass.prototype) + for (const [name, descriptor] of Object.entries(descriptors)) { + if (name !== 'constructor') { + Object.defineProperty(Store.prototype, name, descriptor) + } } } diff --git a/src/main/persistence/loading-store/terminal-binding-recovery.ts b/src/main/persistence/loading-store/terminal-binding-recovery.ts new file mode 100644 index 00000000000..a3e03098b70 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-binding-recovery.ts @@ -0,0 +1,104 @@ +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { toRelaySshPtyId } from '../../providers/ssh-pty-id' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +import type { StoreRuntimeState } from './store-runtime-state' + +type TerminalBindingRecoveryOperationsRuntime = Pick + +export class TerminalBindingRecoveryOperations { + constructor(private readonly runtime: TerminalBindingRecoveryOperationsRuntime) {} + + getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { + const leafIds = new Set() + const visit = (node: TerminalPaneLayoutNode | null): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + if (isTerminalLeafId(node.leafId)) { + leafIds.add(node.leafId) + } + return + } + visit(node.first) + visit(node.second) + } + visit(root) + return leafIds + } + + isRestorablePtyBinding(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + const leases = this.runtime.state.sshRemotePtyLeases?.filter((entry) => + this.sshRemotePtyLeaseMatchesBinding(entry, binding) + ) + return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') + } + + getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { + try { + return toRelaySshPtyId(targetId, ptyId) + } catch { + return ptyId + } + } + + getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { + return toRelaySshPtyId(targetId, ptyId) + } + + sshRemotePtyLeaseMatchesBinding( + lease: SshRemotePtyLease, + binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + } + ): boolean { + const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) + if (lease.ptyId !== bindingPtyId) { + return false + } + // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. + return ( + (binding.targetId === undefined || + binding.targetId === null || + lease.targetId === binding.targetId) && + (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && + (binding.tabId === undefined || lease.tabId === binding.tabId) && + (binding.leafId === undefined || lease.leafId === binding.leafId) + ) + } + + hasRestorableSshRemotePtyLease(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + return ( + this.runtime.state.sshRemotePtyLeases?.some( + (lease) => + this.sshRemotePtyLeaseMatchesBinding(lease, binding) && + lease.state !== 'terminated' && + lease.state !== 'expired' + ) ?? false + ) + } + + getConnectionIdForWorktree(worktreeId: string): string | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return this.runtime.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null + } +} diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts new file mode 100644 index 00000000000..2981cabc114 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -0,0 +1,36 @@ +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' + +const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId' +]) + +export function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { + return Object.keys(patch).some((key) => + WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) + ) +} + +export function deleteRemovedTerminalScrollbackSnapshots( + prior: WorkspaceSessionState | undefined, + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage +): void { + if (!prior) { + return + } + const nextRefs = collectTerminalScrollbackSnapshotRefs(next) + for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { + if (!nextRefs.has(ref)) { + deleteTerminalScrollbackSnapshotSync(ref, storage) + } + } +} diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts new file mode 100644 index 00000000000..50e9d4ae8cc --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -0,0 +1,56 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' + +export function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + +/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. + * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ +export function parseWorkspaceSessionsByHostId( + raw: unknown, + defaults: WorkspaceSessionState +): { partitions: Partial>; repaired: boolean } { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { partitions: {}, repaired: raw !== undefined } + } + let repaired = false + const partitions: Partial> = {} + for (const [key, value] of Object.entries(raw as Record)) { + const hostId = normalizeExecutionHostId(key) + // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. + if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { + continue + } + const result = parseWorkspaceSessionSalvaging(value) + if (!result.ok) { + repaired = true + console.error( + `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, + result.error + ) + continue + } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } + partitions[hostId] = { ...defaults, ...result.value } + } + return { partitions, repaired } +} diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts new file mode 100644 index 00000000000..6f52fdc77ca --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -0,0 +1,242 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { setMigrationUnsupportedPty } from '../../agent-hooks/migration-unsupported-pty-state' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + deleteRemovedTerminalScrollbackSnapshotsAsync, + migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync +} from '../../terminal-scrollback-snapshot-async-migration' +import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { + normalizeWorkspaceSessionPaneIdentities, + remapAcknowledgedAgentPaneKeys, + remapSshRemotePtyLeaseLeafIds, + type WorkspaceSessionPaneIdentityRemap +} from '../restoring-sessions/workspace-pane-normalization' +import { deleteRemovedTerminalScrollbackSnapshots } from './terminal-session-cleanup' +import { + getSessionSnapshotOperationsContext, + type SessionSnapshotOperations +} from './session-snapshot-operations' +import { scheduleSave } from './write-scheduling' + +export function setLocalWorkspaceSession( + owner: SessionSnapshotOperations, + session: PersistedState['workspaceSession'], + deferSnapshotFiles = false +): void { + const context = getSessionSnapshotOperationsContext(owner) + const prior = context.runtime.state.workspaceSession + session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) + session = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + ) + + // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. + const normalized = normalizeWorkspaceSessionPaneIdentities(session, prior?.terminalLayoutsByTabId) + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( + context.runtime.state.ui?.acknowledgedAgentsByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + if (remappedAcknowledgements.changed) { + context.runtime.state.ui = { + ...context.runtime.state.ui, + acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + } + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + session = normalized.session + const remapsByHostId = new Map([ + [LOCAL_EXECUTION_HOST_ID, normalized] + ]) + const remappedLeases = remapSshRemotePtyLeaseLeafIds( + context.runtime.state.sshRemotePtyLeases ?? [], + remapsByHostId, + new Set(context.sessions.getWorkspaceSessionHostIds()) + ) + if (remappedLeases.changed) { + context.runtime.state.sshRemotePtyLeases = remappedLeases.leases + } + if (session && prior) { + const priorTabs = prior.tabsByWorktree ?? {} + const nextTabs = session.tabsByWorktree ?? {} + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + for (const [worktreeId, tabs] of Object.entries(nextTabs)) { + const priorList = priorTabs[worktreeId] + if (!priorList) { + continue + } + for (const tab of tabs) { + if (tab.ptyId) { + continue + } + const priorTab = priorList.find((t) => t.id === tab.id) + if ( + priorTab?.ptyId && + context.bindingRecovery.isRestorablePtyBinding({ + ptyId: priorTab.ptyId, + worktreeId, + targetId: context.bindingRecovery.getConnectionIdForWorktree(worktreeId), + tabId: tab.id + }) + ) { + tab.ptyId = priorTab.ptyId + } + } + } + const priorLayouts = prior.terminalLayoutsByTabId ?? {} + const nextLayouts = session.terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(nextLayouts)) { + const priorLayout = priorLayouts[tabId] + if (!priorLayout?.ptyIdsByLeafId) { + continue + } + const incoming = layout.ptyIdsByLeafId ?? {} + const incomingHasAnyBinding = Object.keys(incoming).length > 0 + const liveLeafIds = context.bindingRecovery.getTerminalLayoutLeafIds(layout.root) + const worktreeId = worktreeIdByTabId.get(tabId) + const targetId = worktreeId + ? context.bindingRecovery.getConnectionIdForWorktree(worktreeId) + : null + const restorableBindings = Object.fromEntries( + Object.entries(priorLayout.ptyIdsByLeafId).filter( + ([leafId, ptyId]) => + liveLeafIds.has(leafId) && + incoming[leafId] === undefined && + // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. + (incomingHasAnyBinding + ? context.bindingRecovery.hasRestorableSshRemotePtyLease({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + }) + : context.bindingRecovery.isRestorablePtyBinding({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + })) + ) + ) + if (Object.keys(restorableBindings).length > 0) { + layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } + // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. + const buffersByLeafId = preserveMissingLeafRecordEntries( + priorLayout.buffersByLeafId, + layout.buffersByLeafId, + liveLeafIds + ) + const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( + priorLayout.scrollbackRefsByLeafId, + layout.scrollbackRefsByLeafId, + liveLeafIds + ) + const titlesByLeafId = preserveMissingLeafRecordEntries( + priorLayout.titlesByLeafId, + layout.titlesByLeafId, + liveLeafIds + ) + if (buffersByLeafId) { + layout.buffersByLeafId = buffersByLeafId + } + if (scrollbackRefsByLeafId) { + layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId + } + if (titlesByLeafId) { + layout.titlesByLeafId = titlesByLeafId + } + } + } + } + session = pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + if (!deferSnapshotFiles) { + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + session = migratedScrollback.session + deleteRemovedTerminalScrollbackSnapshots( + prior, + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + context.runtime.state.workspaceSession = session + if (deferSnapshotFiles) { + enqueueTerminalScrollbackSnapshotWork(owner, prior, session) + } + scheduleSave(context.scheduling) +} + +export function enqueueTerminalScrollbackSnapshotWork( + owner: SessionSnapshotOperations, + prior: WorkspaceSessionState | undefined, + staged: WorkspaceSessionState +): void { + const context = getSessionSnapshotOperationsContext(owner) + const previous = context.runtime.pendingSnapshotFileWork ?? Promise.resolve() + const work = previous + .then(async () => { + if (context.runtime.state.workspaceSession !== staged) { + if (context.runtime.state.workspaceSession) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + context.runtime.state.workspaceSession, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + return + } + const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( + staged, + context.runtime.terminalScrollbackSnapshotStorage + ) + const current = + context.runtime.state.workspaceSession === staged + ? migrated + : context.runtime.state.workspaceSession + if (context.runtime.state.workspaceSession === staged) { + context.runtime.state.workspaceSession = migrated + } else if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + migrated, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + }) + .catch((error) => { + console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) + }) + .finally(() => { + if (context.runtime.pendingSnapshotFileWork === work) { + context.runtime.pendingSnapshotFileWork = null + } + }) + context.runtime.pendingSnapshotFileWork = work +} diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts new file mode 100644 index 00000000000..81424dc931b --- /dev/null +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -0,0 +1,276 @@ +import { writeFileSync, renameSync, unlinkSync } from 'node:fs' +import { rename, rm, writeFile } from 'node:fs/promises' +import { durableWriteTempPath } from '../../durable-file-write' +import { getGithubCacheFile } from './user-data-path' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +type WriteFlushBarrierOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'dataFile' + | 'firstPendingSaveAt' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'lastDurableWriteGeneration' + | 'pendingGithubCacheWrite' + | 'quitFlushPromise' + | 'quitFlushStarted' + | 'staleGithubCacheTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') +type WriteFlushBarrierOperationsContext = { + runtime: WriteFlushBarrierOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteFlushBarrierOperations { + readonly [writeFlushBarrierOperationsContext]: WriteFlushBarrierOperationsContext + + constructor(runtime: WriteFlushBarrierOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeFlushBarrierOperationsContext] = { runtime, writes } + } + + flush(): void { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + return + } + try { + this[writeFlushBarrierOperationsContext].writes.flushOrThrow() + } catch (err) { + console.error('[persistence] Failed to flush state:', err) + } + try { + this[writeFlushBarrierOperationsContext].writes.flushActiveViewPreferenceOrThrow() + } catch (err) { + console.error('[active-view] Failed to flush preference:', err) + } + writeGithubCacheSnapshotSync(this) + } + + flushAsync(): Promise { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true + this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( + this, + true + ).catch(() => {}) + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + + flushPendingAsync(): Promise { + // Best-effort callers must not livelock while the live app keeps mutating state. + return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) + } + + flushPendingOrThrowAsync( + options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} + ): Promise { + if ( + this[writeFlushBarrierOperationsContext].runtime.writesFrozen || + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + return Promise.reject(new Error('Cannot flush while persistence is finalized')) + } + return flushCurrentStateAsync( + this, + false, + options.signal, + options.drainToStableGeneration, + true + ) + } +} + +export async function flushDurableStateOrThrowAsync( + owner: WriteFlushBarrierOperations +): Promise { + if ( + owner[writeFlushBarrierOperationsContext].runtime.writesFrozen || + owner[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + throw new Error('Cannot flush while persistence is finalized') + } + for (;;) { + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function flushCurrentStateAsync( + owner: WriteFlushBarrierOperations, + final: boolean, + signal?: AbortSignal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false +): Promise { + const requiredDurableGeneration = requireInitialGenerationDurable + ? owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + try { + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + } catch (error) { + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + throw error + } + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= + requiredDurableGeneration + ) { + break + } + continue + } + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function writeGithubCacheSnapshotAsync( + owner: WriteFlushBarrierOperations, + drainToStableGeneration = true, + signal?: AbortSignal +): Promise { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + const previousWrite = + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite ?? + owner[writeFlushBarrierOperationsContext].runtime.staleGithubCacheTempCleanup + const nextWrite = previousWrite + .then(async () => { + while (owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile( + owner[writeFlushBarrierOperationsContext].runtime.dataFile + ) + const tmpFile = durableWriteTempPath(cacheFile) + let renamed = false + try { + await writeFile( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + await rename(tmpFile, cacheFile) + renamed = true + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + if (!drainToStableGeneration) { + break + } + } + }) + .catch((err) => { + console.warn('[persistence] Failed to write github cache snapshot:', err) + }) + .finally(() => { + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite === nextWrite) { + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = null + } + }) + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = nextWrite + await nextWrite +} + +export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite) { + void writeGithubCacheSnapshotAsync(owner) + return + } + const cacheFile = getGithubCacheFile(owner[writeFlushBarrierOperationsContext].runtime.dataFile) + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const tmpFile = durableWriteTempPath(cacheFile) + try { + writeFileSync( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + renameSync(tmpFile, cacheFile) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } +} + +export function installWriteFlushBarrierOperationsContext( + target: object, + source: WriteFlushBarrierOperations +): void { + Object.defineProperty(target, writeFlushBarrierOperationsContext, { + value: source[writeFlushBarrierOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts new file mode 100644 index 00000000000..bf9fad874c4 --- /dev/null +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -0,0 +1,71 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +const SAVE_DEBOUNCE_MS = 1_000 +const SAVE_MAX_WAIT_MS = 5_000 + +type WriteSchedulingOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'firstPendingSaveAt' + | 'pendingWrite' + | 'quitFlushStarted' + | 'writeGeneration' + | 'writeTimer' +> + +const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') +type WriteSchedulingOperationsContext = { + runtime: WriteSchedulingOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteSchedulingOperations { + readonly [writeSchedulingOperationsContext]: WriteSchedulingOperationsContext + + constructor(runtime: WriteSchedulingOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeSchedulingOperationsContext] = { runtime, writes } + } + + async waitForPendingWrite(): Promise { + await Promise.all([ + this[writeSchedulingOperationsContext].runtime.pendingWrite, + this[writeSchedulingOperationsContext].runtime.activeViewPreference.waitForPendingWrite() + ]) + } +} + +export function scheduleSave(owner: WriteSchedulingOperations): void { + // Why: once the quit flush has snapshotted, a newly debounced write would fire during + // teardown with nothing awaiting it, and the process can exit mid-rename. The quit + // flush is the last write by construction. + if (owner[writeSchedulingOperationsContext].runtime.quitFlushStarted) { + return + } + owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 + const now = Date.now() + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now + if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + } + const untilMaxWait = Math.max( + 0, + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now + ) + const delay = Math.min(SAVE_DEBOUNCE_MS, untilMaxWait) + owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { + owner[writeSchedulingOperationsContext].runtime.writeTimer = null + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null + void enqueueWrite(owner[writeSchedulingOperationsContext].writes) + }, delay) +} + +export function installWriteSchedulingOperationsContext( + target: object, + source: WriteSchedulingOperations +): void { + Object.defineProperty(target, writeSchedulingOperationsContext, { + value: source[writeSchedulingOperationsContext] + }) +} diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts index 77c6547d000..1d3c2cbbcaf 100644 --- a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -8,11 +8,10 @@ import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-co import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from './session-owner-removal' export type FolderWorkspaceMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 677655c575e..801746f91ff 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -7,7 +7,6 @@ import type { import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { getAutomationContextsForRepo, getAutomationSchedulerOwner, @@ -16,7 +15,7 @@ import { } from './automation-context-migration' export type AutomationDefinitionOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordCreated: () => void } diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index f83117edf8b..d1ebfe31c66 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -10,7 +10,6 @@ import { nextAutomationRunNumber, pruneAutomationRuns } from '../../../shared/automation-run-retention' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { normalizeAutomationPrecheckResult, normalizeAutomationRunOutputSnapshot, @@ -20,7 +19,7 @@ import { } from './automation-context-migration' export type AutomationRunOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordManualRun: () => void getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index b52de27baa0..25788a158cb 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -1,12 +1,12 @@ import type { Automation } from '../../../shared/automations-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' export function advanceAutomationNextRun( - state: StoreOwnedPersistedState, + state: PersistedState, flush: () => void, id: string, now = Date.now() diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts index 95a5f2f1627..b3918a9a98f 100644 --- a/src/main/persistence/tracking-repos/project-group-operations.ts +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -6,11 +6,10 @@ import { normalizeProjectGroupName } from '../../../shared/project-groups' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal' export type ProjectGroupMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index baaae4777f6..c6a8441f58a 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -13,12 +13,11 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { makeProjectHostSetupId } from './project-host-compatibility' import { repoGitUsernameCacheKey } from './repo-hydration' export type ProjectHostMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState gitUsernameCache: Map hydrateRepo: (repo: Repo) => Repo updateRepoBackedProjectHostSetup: ( diff --git a/src/main/persistence/tracking-repos/project-host-setup-update.ts b/src/main/persistence/tracking-repos/project-host-setup-update.ts index 2a72ab055c8..05d291bd171 100644 --- a/src/main/persistence/tracking-repos/project-host-setup-update.ts +++ b/src/main/persistence/tracking-repos/project-host-setup-update.ts @@ -1,11 +1,10 @@ -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { RepoUpdatePersistenceOperations } from './repo-update-operations' export type ProjectHostSetupUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState updateRepo: RepoUpdatePersistenceOperations['updateRepo'] scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-order-operations.ts b/src/main/persistence/tracking-repos/repo-order-operations.ts index d578b20dcf7..55cec62226e 100644 --- a/src/main/persistence/tracking-repos/repo-order-operations.ts +++ b/src/main/persistence/tracking-repos/repo-order-operations.ts @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type RepoOrderMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 669dce1a2cc..f39e0e1e26f 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -5,11 +5,10 @@ import { getRepoExecutionHostId } from '../../../shared/execution-host' import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' export type RepoUpdateMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void hydrateRepo: (repo: Repo) => Repo diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index 50c4fe5f745..7032ef2ed60 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -1,7 +1,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getWorktreeIdFromHostIdentity, @@ -9,7 +9,7 @@ import { } from '../../../shared/worktree/host-qualified-identity' export function pruneWorktreeStateForRepo( - state: StoreOwnedPersistedState, + state: PersistedState, id: string, hostId: ExecutionHostId | null, pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index a9d3397aa7c..d9451258634 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -1,11 +1,11 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { PersistedState } from '../../../shared/persisted-state-types' import { getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' /** * Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place; @@ -13,7 +13,7 @@ import type { StoreOwnedPersistedState } from '../loading-store/store-owned-stat * See `Store.migrateWorktreeIdentity` for why the rename happens. */ export function migrateWorktreeIdentity( - state: StoreOwnedPersistedState, + state: PersistedState, oldWorktreeId: string, newWorktreeId: string ): boolean { diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts index 0ddba9dc730..5886ebb7196 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeWorktreeLinkedItemMetadata } from './worktree-metadata-normalization' // Only the presence of an entry matters here; the normalizer never reads its linked-item fields. @@ -8,20 +8,20 @@ function makeMeta(): WorktreeMeta { return { createdAt: 1 } as WorktreeMeta } -function makeState(overrides: Partial): StoreOwnedPersistedState { +function makeState(overrides: Partial): PersistedState { return { worktreeMeta: {}, worktreeLineageById: {}, workspaceLineageByChildKey: {}, ...overrides - } as StoreOwnedPersistedState + } as PersistedState } describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null lineage map repair as changed so the load path re-saves it', () => { const state = makeState({ worktreeMeta: { 'r1::/tmp/wt': makeMeta() }, - worktreeLineageById: null as unknown as StoreOwnedPersistedState['worktreeLineageById'] + worktreeLineageById: null as unknown as PersistedState['worktreeLineageById'] }) // Without this the map stays null on disk and is repaired again on every reload. @@ -32,8 +32,7 @@ describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null child-key lineage map repair as changed', () => { const state = makeState({ worktreeMeta: {}, - workspaceLineageByChildKey: - null as unknown as StoreOwnedPersistedState['workspaceLineageByChildKey'] + workspaceLineageByChildKey: null as unknown as PersistedState['workspaceLineageByChildKey'] }) expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index 72f64b82906..81c6e94843e 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -14,14 +14,14 @@ import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. export const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 export const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 -export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { +export function gcStaleWorktreeMeta(state: PersistedState): number { // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. // Companion lineage maps get the same guard because the deletes below index them directly. state.worktreeMeta ??= {} @@ -80,7 +80,7 @@ export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { return removed } -export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean { +export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { // Why: a hand-corrupted null lineage map would throw on the companion deletes below. The repair // itself is dirty state — without it the map stays null on disk and is repaired again every load. let changed = diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts new file mode 100644 index 00000000000..ee60b254134 --- /dev/null +++ b/src/main/providers/ssh-git-noninteractive-provider.ts @@ -0,0 +1,166 @@ +import type { CommitMessagePlan } from '../../shared/commit-message-plan' +import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' +import { SshGitReadProvider } from './ssh-git-read-provider' + +type NonInteractiveExecQueueEntry = { + started: boolean + canceled: boolean + done: Promise + release: () => void +} + +const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 + +export class SshGitNoninteractiveProvider extends SshGitReadProvider { + private readonly nonInteractiveExecQueues = new Map() + + async executeCommitMessagePlan( + plan: CommitMessagePlan, + cwd: string, + timeoutMs: number, + operation = 'commit-message' + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary: plan.binary, + args: plan.args, + cwd, + stdin: plan.stdinPayload, + timeoutMs, + operation + }, + undefined, + operation + ) + } + + async execNonInteractive( + binary: string, + args: string[], + cwd: string, + timeoutMs: number, + signal?: AbortSignal, + env?: Record + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary, + args, + cwd, + stdin: null, + timeoutMs, + ...(env ? { env } : {}) + }, + signal + ) + } + + async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { + const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) + const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) + if (queuedEntry) { + queuedEntry.canceled = true + return + } + await this.cancelActiveNonInteractiveExec(cwd, operation) + } + + async cancelGenerateCommitMessage( + worktreePath: string, + operation = 'commit-message' + ): Promise { + await this.cancelNonInteractiveExec(worktreePath, operation) + } + + private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { + try { + await this.mux.request('agent.cancelExec', { + cwd, + ...(operation ? { operation } : {}) + }) + } catch { + // Best-effort: callers are already unwinding after cancellation. + } + } + + private nonInteractiveLaneKey(cwd: string, operation?: string): string { + return JSON.stringify([operation || 'default', cwd]) + } + + private async runQueuedNonInteractiveExec( + cwd: string, + payload: { + binary: string + args: string[] + cwd: string + stdin: string | null + timeoutMs: number + env?: Record + operation?: string + }, + signal?: AbortSignal, + operation?: string + ): Promise { + const laneKey = this.nonInteractiveLaneKey(cwd, operation) + const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] + const previous = queue.at(-1)?.done ?? Promise.resolve() + let releaseEntry!: () => void + const entry: NonInteractiveExecQueueEntry = { + started: false, + canceled: false, + done: previous + .catch(() => {}) + .then( + () => + new Promise((resolve) => { + releaseEntry = resolve + }) + ), + release: () => releaseEntry() + } + queue.push(entry) + this.nonInteractiveExecQueues.set(laneKey, queue) + const abortEntry = (): void => { + if (!entry.started) { + entry.canceled = true + return + } + void this.cancelActiveNonInteractiveExec(cwd, operation) + } + if (signal?.aborted) { + entry.canceled = true + } else { + signal?.addEventListener('abort', abortEntry, { once: true }) + } + + await previous.catch(() => {}) + try { + if (entry.canceled) { + return { + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + canceled: true + } + } + entry.started = true + return (await this.mux.request('agent.execNonInteractive', payload, { + timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS + })) as RemoteCommitMessageExecResult + } finally { + signal?.removeEventListener('abort', abortEntry) + entry.release() + const currentQueue = this.nonInteractiveExecQueues.get(laneKey) + const entryIndex = currentQueue?.indexOf(entry) ?? -1 + if (entryIndex >= 0) { + currentQueue?.splice(entryIndex, 1) + } + if (currentQueue?.length === 0) { + this.nonInteractiveExecQueues.delete(laneKey) + } + } + } +} diff --git a/src/main/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts new file mode 100644 index 00000000000..dd0915dafe5 --- /dev/null +++ b/src/main/providers/ssh-git-provider-api.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { IGitProvider } from './types' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux } from './ssh-git-provider-test-harness' + +describe('SshGitProvider public API parity', () => { + it('retains every historical provider operation', () => { + const provider: IGitProvider & SshGitProvider = new SshGitProvider( + 'conn-1', + createMockMux() as never + ) + const methods = [ + 'getConnectionId', + 'getHostPlatform', + 'getStatus', + 'getSubmoduleStatus', + 'checkIgnoredPaths', + 'getHistory', + 'commit', + 'getStagedCommitContext', + 'executeCommitMessagePlan', + 'execNonInteractive', + 'cancelNonInteractiveExec', + 'cancelGenerateCommitMessage', + 'getDiff', + 'stageFile', + 'unstageFile', + 'bulkStageFiles', + 'bulkUnstageFiles', + 'discardChanges', + 'bulkDiscardChanges', + 'detectConflictOperation', + 'abortMerge', + 'abortRebase', + 'checkoutBranch', + 'listLocalBranches', + 'getBranchCompare', + 'getCommitCompare', + 'getUpstreamStatus', + 'pushBranch', + 'pullBranch', + 'fastForwardBranch', + 'rebaseFromBase', + 'fetchRemote', + 'syncForkDefaultBranch', + 'fetchRemoteTrackingRef', + 'fetchGitLabMergeRequestHead', + 'fetchGitHubPullRequestHead', + 'getBranchDiff', + 'getCommitDiff', + 'listWorktrees', + 'addWorktree', + 'removeWorktree', + 'worktreeIsClean', + 'refreshLocalBaseRefForWorktreeCreate', + 'renameCurrentBranch', + 'forceDeletePreservedBranch', + 'exec', + 'clone', + 'isGitRepoAsync', + 'isGitRepo', + 'getRemoteFileUrl', + 'getRemoteCommitUrl' + ] as const + + expect(methods).toHaveLength(51) + for (const method of methods) { + expect(provider[method], method).toBeTypeOf('function') + } + }) +}) diff --git a/src/main/providers/ssh-git-provider-exec.test.ts b/src/main/providers/ssh-git-provider-exec.test.ts index 9fefe4abfda..ef0c3198a8d 100644 --- a/src/main/providers/ssh-git-provider-exec.test.ts +++ b/src/main/providers/ssh-git-provider-exec.test.ts @@ -209,6 +209,40 @@ describe('SshGitProvider', () => { await second }) + it('isolates non-interactive lanes between provider instances', async () => { + const completeRequests: (() => void)[] = [] + mux.request.mockImplementation( + () => + new Promise((resolve) => { + completeRequests.push(() => + resolve({ stdout: '', stderr: '', exitCode: 0, timedOut: false }) + ) + }) + ) + const otherProvider = new SshGitProvider('conn-2', mux as never) + + const first = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + const second = otherProvider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenCalledTimes(2) + completeRequests.forEach((complete) => complete()) + await Promise.all([first, second]) + }) + + it('forwards leading global Git options without reordering argv', async () => { + mux.request.mockResolvedValue({ stdout: '', stderr: '' }) + const args = ['-c', 'maintenance.auto=false', 'fetch', 'origin', 'main'] + + await provider.exec(args, '/home/user/repo') + + expect(mux.request).toHaveBeenCalledWith('git.exec', { + args, + cwd: '/home/user/repo', + __streamResponse: true + }) + }) + it('cancels a queued non-interactive exec without canceling the active relay child', async () => { const completeRequests: (() => void)[] = [] mux.request.mockImplementation( diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 8086548ed5d..5fd475a0698 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -1,229 +1,16 @@ -/* eslint-disable max-lines -- Why: this provider mirrors IGitProvider one - method per RPC call (~16 methods). Splitting it would only add - indirection — every method is a 1:1 forwarder to a relay RPC plus a - small amount of param plumbing. */ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { GitProviderStatusOptions, IGitProvider } from './types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import type { CommitMessagePlan } from '../../shared/commit-message-plan' -import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' +import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from '../git/max-buffer-overflow' -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' -import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' -import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' - -type NonInteractiveExecQueueEntry = { - started: boolean - canceled: boolean - done: Promise - release: () => void -} - -const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 -const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const - -function isJsonRpcMethodNotFoundError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound -} - -// Why: the relay returns the durable ref it wrote; re-deriving it on the client -// can disagree (URL normalization) and leave resolve looking at the wrong path. -function readDurableReviewHeadLocalRef( - result: unknown, - kind: 'pull request' | 'merge request' -): string { - if (result && typeof result === 'object' && 'localRef' in result) { - const localRef = (result as { localRef: unknown }).localRef - if (typeof localRef === 'string') { - const trimmed = localRef.trim() - if (trimmed.startsWith('refs/orca/')) { - return trimmed - } - } - } - throw new Error( - `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` - ) -} - -function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { - if (entries.length === 0) { - return undefined - } - return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') -} - -function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { - const trackedLines = (stdout ?? '') - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) - return trackedLines.length > 0 ? trackedLines.join('\n') : undefined -} - -export class SshGitProvider implements IGitProvider { - private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() - private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() - private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() - - private connectionId: string - private mux: SshChannelMultiplexer - private nonInteractiveExecQueues = new Map() - - private async runWithGitReadInvalidation(run: () => Promise): Promise { - this.invalidateGitReads() - try { - return await run() - } finally { - this.invalidateGitReads() - } - } - - private invalidateGitReads(): void { - this.gitDiffReadDedupe.clear() - this.statusReadLeaseOwner.invalidate() - this.upstreamStatusReadOwner.invalidate() - } - - private loggedWorktreeIsCleanFallback = false - - constructor( - connectionId: string, - mux: SshChannelMultiplexer, - private readonly hostPlatform: RemoteHostPlatform | null = null - ) { - this.connectionId = connectionId - this.mux = mux - } - - getConnectionId(): string { - return this.connectionId - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.hostPlatform - } - - async getStatus( - worktreePath: string, - options?: GitProviderStatusOptions - ): Promise { - this.gitDiffReadDedupe.clear() - const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} - const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache - ? { bypassEffectiveUpstreamNegativeCache: true } - : {} - const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} - const branchLineTotalArgs = - options?.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } - const request = { - worktreePath, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs, - ...branchLineTotalArgs - } - const key = stableInFlightKey([ - worktreePath, - options?.includeIgnored === true, - options?.bypassEffectiveUpstreamNegativeCache === true, - options?.reuseLineStats === true, - // Why: the result carries a total only for callers who asked, and only for - // this fork point, so a shared lease must never serve one to the other. - options?.branchLineTotalMergeBase ?? '' - ]) - return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { - return (await this.mux.request('git.status', request, { - signal: sharedSignal - })) as GitStatusResult - }) - } - - async getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - area: GitStagingArea = 'unstaged' - ): Promise { - // Why: mirror getStatus() — refreshing submodule state must invalidate - // in-flight diff reads so a later diff can't reuse a stale pending RPC. - this.gitDiffReadDedupe.clear() - try { - return (await this.mux.request('git.submoduleStatus', { - worktreePath, - submodulePath, - area - })) as GitStatusResult - } catch (error) { - // Why: a newer desktop client may talk to an older relay that predates - // git.submoduleStatus; surface an actionable reconnect hint instead of a - // raw JSON-RPC method-not-found error in Source Control. - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } - } - - async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { - return (await this.mux.request('git.checkIgnored', { - worktreePath, - paths: relativePaths - })) as string[] - } - - async getHistory( - worktreePath: string, - options: GitHistoryOptions = {} - ): Promise { - return (await this.mux.request('git.history', { - worktreePath, - ...options - })) as GitHistoryResult - } - - async commit( - worktreePath: string, - message: string - ): Promise<{ success: boolean; error?: string }> { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.commit', { - worktreePath, - message - })) as { success: boolean; error?: string } - ) - } +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { IGitProvider } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitWorktreeProvider } from './ssh-git-worktree-provider' +export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvider { async getStagedCommitContext(worktreePath: string): Promise { const branchPromise = this.exec(['branch', '--show-current'], worktreePath).catch(() => ({ stdout: '' @@ -247,9 +34,6 @@ export class SshGitProvider implements IGitProvider { if (!isMaxBufferOverflowError(error)) { throw error } - // Why: a very large staged diff can overflow the remote exec buffer. The - // patch is optional context (truncated later anyway), so degrade to the - // file-name summary instead of failing commit-message generation. console.warn( '[ssh-git] Staged patch too large to read; using file summary only:', describeMaxBufferOverflowError(error) @@ -262,594 +46,6 @@ export class SshGitProvider implements IGitProvider { } } - async executeCommitMessagePlan( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number, - operation = 'commit-message' - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary: plan.binary, - args: plan.args, - cwd, - stdin: plan.stdinPayload, - timeoutMs, - operation - }, - undefined, - operation - ) - } - - async execNonInteractive( - binary: string, - args: string[], - cwd: string, - timeoutMs: number, - signal?: AbortSignal, - env?: Record - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary, - args, - cwd, - stdin: null, - timeoutMs, - ...(env ? { env } : {}) - }, - signal - ) - } - - async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { - const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) - const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) - if (queuedEntry) { - queuedEntry.canceled = true - return - } - await this.cancelActiveNonInteractiveExec(cwd, operation) - } - - private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { - try { - await this.mux.request('agent.cancelExec', { - cwd, - ...(operation ? { operation } : {}) - }) - } catch { - // Best-effort: callers are already unwinding after cancellation. - } - } - - async cancelGenerateCommitMessage( - worktreePath: string, - operation = 'commit-message' - ): Promise { - // Why: best-effort — the relay returns `{canceled: false}` when there is - // nothing in flight. Callers should not block UI updates on this. - await this.cancelNonInteractiveExec(worktreePath, operation) - } - - private nonInteractiveLaneKey(cwd: string, operation?: string): string { - return JSON.stringify([operation || 'default', cwd]) - } - - private async runQueuedNonInteractiveExec( - cwd: string, - payload: { - binary: string - args: string[] - cwd: string - stdin: string | null - timeoutMs: number - env?: Record - operation?: string - }, - signal?: AbortSignal, - operation?: string - ): Promise { - const laneKey = this.nonInteractiveLaneKey(cwd, operation) - const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] - const previous = queue.at(-1)?.done ?? Promise.resolve() - let releaseEntry!: () => void - const entry: NonInteractiveExecQueueEntry = { - started: false, - canceled: false, - done: previous - .catch(() => {}) - .then( - () => - new Promise((resolve) => { - releaseEntry = resolve - }) - ), - release: () => releaseEntry() - } - queue.push(entry) - this.nonInteractiveExecQueues.set(laneKey, queue) - const abortEntry = (): void => { - if (!entry.started) { - entry.canceled = true - return - } - void this.cancelActiveNonInteractiveExec(cwd, operation) - } - if (signal?.aborted) { - entry.canceled = true - } else { - signal?.addEventListener('abort', abortEntry, { once: true }) - } - - // Why: the SSH relay tracks children per operation; serialize only matching - // lanes so commit-message and PR-field generation can coexist. - await previous.catch(() => {}) - try { - if (entry.canceled) { - return { - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - canceled: true - } - } - entry.started = true - return (await this.mux.request('agent.execNonInteractive', payload, { - timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS - })) as RemoteCommitMessageExecResult - } finally { - signal?.removeEventListener('abort', abortEntry) - entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(laneKey) - const entryIndex = currentQueue?.indexOf(entry) ?? -1 - if (entryIndex >= 0) { - currentQueue?.splice(entryIndex, 1) - } - if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(laneKey) - } - } - } - - async getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead?: boolean - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => - (await requestGitStreamable(this.mux, 'git.diff', { - worktreePath, - filePath, - staged, - compareAgainstHead - })) as GitDiffResult - ) as Promise - } - - async stageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.stage', { worktreePath, filePath }) - }) - } - - async unstageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.unstage', { worktreePath, filePath }) - }) - } - - async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkStage', { worktreePath, filePaths }) - }) - } - - async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) - }) - } - - async discardChanges(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.discard', { worktreePath, filePath }) - }) - } - - async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) - }) - } - - async detectConflictOperation(worktreePath: string): Promise { - return (await this.mux.request('git.conflictOperation', { - worktreePath - })) as GitConflictOperation - } - - async abortMerge(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortMerge', { worktreePath }) - }) - } - - async abortRebase(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortRebase', { worktreePath }) - }) - } - - async checkoutBranch(worktreePath: string, branch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.checkout', { worktreePath, branch }) - }) - } - - async listLocalBranches( - worktreePath: string - ): Promise<{ current: string | null; branches: string[] }> { - return (await this.mux.request('git.localBranches', { worktreePath })) as { - current: string | null - branches: string[] - } - } - - async getBranchCompare(worktreePath: string, baseRef: string): Promise { - return (await this.mux.request('git.branchCompare', { - worktreePath, - baseRef - })) as GitBranchCompareResult - } - - async getCommitCompare(worktreePath: string, commitId: string): Promise { - return (await this.mux.request('git.commitCompare', { - worktreePath, - commitId - })) as GitCommitCompareResult - } - - async getUpstreamStatus( - worktreePath: string, - pushTarget?: GitPushTarget - ): Promise { - return this.upstreamStatusReadOwner.read( - { kind: 'ssh-provider' }, - worktreePath, - pushTarget, - async () => - (await this.mux.request('git.upstreamStatus', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - })) as GitUpstreamStatus - ) - } - - async pushBranch( - worktreePath: string, - publish = false, - pushTarget?: GitPushTarget, - options: { forceWithLease?: boolean } = {} - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.push', { - worktreePath, - publish, - pushTarget, - ...(options.forceWithLease === true ? { forceWithLease: true } : {}) - }) - }) - } - - async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fastForward', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - }) - }) - } - - async rebaseFromBase(worktreePath: string, baseRef: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request( - 'git.rebaseFromBase', - { worktreePath, baseRef }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - } - - async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async syncForkDefaultBranch( - worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream - ): Promise { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.forkSync', { - worktreePath, - ...(expectedUpstream ? { expectedUpstream } : {}) - })) as GitForkSyncResult - ) - } - - async fetchRemoteTrackingRef( - worktreePath: string, - remote: string, - branch: string, - ref: string, - options?: { skipAutoMaintenance?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetchRemoteTrackingRef', { - worktreePath, - remote, - branch, - ref, - ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) - }) - }) - } - - async fetchGitLabMergeRequestHead( - worktreePath: string, - remote: string, - mrIid: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - // Why: the durable-ref RPC is a NEW method name. Old relays only implement - // FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead, so calling the ref - // variant makes them return -32601 rather than silently no-op the durable - // ref (which would leave the client resolving a stale/missing MR head). - const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { - worktreePath, - remote, - mrIid - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'merge request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate the durable-ref MR fetch; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async fetchGitHubPullRequestHead( - worktreePath: string, - remote: string, - prNumber: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitHubPullRequestHead', { - worktreePath, - remote, - prNumber - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'pull request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.fetchGitHubPullRequestHead; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async getBranchDiff( - worktreePath: string, - baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } - ): Promise { - const keyOptions = options ?? {} - const { headOid: rawHeadOid, ...relayOptions } = keyOptions - // Why: compare snapshots type headOid as `string | null`, so collapse an - // unpinned null to absent instead of putting a field on the wire that a - // pinned-OID relay must reject. - const headOid = rawHeadOid == null ? undefined : rawHeadOid - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'branchDiff', - worktreePath, - baseRef, - keyOptions.includePatch ?? null, - keyOptions.filePath ?? null, - keyOptions.oldPath ?? null, - headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid - ]), - async () => - (await requestGitStreamable(this.mux, 'git.branchDiff', { - worktreePath, - baseRef, - ...relayOptions, - ...(headOid === undefined ? {} : { headOid }) - })) as GitDiffResult[] - ) as Promise - } - - async getCommitDiff( - worktreePath: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null - ]), - async () => - (await requestGitStreamable(this.mux, 'git.commitDiff', { - worktreePath, - ...args - })) as GitDiffResult - ) as Promise - } - - async listWorktrees( - repoPath: string, - options?: { signal?: AbortSignal } - ): Promise { - return (await this.mux.request( - 'git.listWorktrees', - { - repoPath - }, - { signal: options?.signal } - )) as GitWorktreeInfo[] - } - - async addWorktree( - repoPath: string, - branchName: string, - targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.addWorktree', { - repoPath, - branchName, - targetDir, - ...options - }) - }) - } - - async removeWorktree( - worktreePath: string, - force?: boolean, - options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise { - return this.runWithGitReadInvalidation( - async () => - ((await this.mux.request('git.removeWorktree', { - worktreePath, - force, - ...options - })) ?? {}) as RemoveWorktreeResult - ) - } - - async worktreeIsClean( - worktreePath: string, - options: { includeUntracked?: boolean } = {} - ): Promise<{ clean: boolean; stdout?: string }> { - try { - const result = (await this.mux.request('git.worktreeIsClean', { - worktreePath, - ...(options.includeUntracked === false ? { includeUntracked: false } : {}) - })) as { - clean: boolean - stdout?: string - } - if (options.includeUntracked === false) { - if (!result.clean && result.stdout === undefined) { - return result - } - const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) - return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } - } - return result - } catch (error) { - if (!isJsonRpcMethodNotFoundError(error)) { - throw error - } - if (!this.loggedWorktreeIsCleanFallback) { - this.loggedWorktreeIsCleanFallback = true - console.warn( - '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' - ) - } - // Why: existing SSH relays may predate git.worktreeIsClean, but git.status - // is a narrow relay RPC and avoids the generic git.exec allowlist. - const status = await this.getStatus(worktreePath) - const entries = - options.includeUntracked === false - ? status.entries.filter((entry) => entry.area !== 'untracked') - : status.entries - const clean = entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } - } - } - - async refreshLocalBaseRefForWorktreeCreate(args: { - repoPath: string - fullRef: string - remoteTrackingRef: string - ownerWorktreePath?: string - checkOnly?: boolean - }): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) - }) - } - - async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) - }) - } - - async forceDeletePreservedBranch( - repoPath: string, - branchName: string, - expectedHead: string - ): Promise { - try { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.forceDeletePreservedBranch', { - repoPath, - branchName, - expectedHead - }) - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.forceDeletePreservedBranch; surface - // a reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - async exec( args: string[], cwd: string, @@ -862,10 +58,7 @@ export class SshGitProvider implements IGitProvider { const result = gitExecMutatesRepository(args) ? await this.runWithGitReadInvalidation(run) : await run() - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } async clone( @@ -897,10 +90,7 @@ export class SshGitProvider implements IGitProvider { { args, cwd, progressId }, { signal: options?.signal, timeoutMs: options?.timeoutMs } ) - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } catch (error) { if (isJsonRpcMethodNotFoundError(error)) { throw new Error( @@ -921,16 +111,11 @@ export class SshGitProvider implements IGitProvider { } } - // Why: isGitRepo requires synchronous return in the interface, but remote - // operations are async. We always return true for remote paths since the - // relay validates git repos on its side. The renderer already guards git - // operations behind worktree registration which validates the path. + // Remote paths are validated asynchronously by the relay before registration. isGitRepo(_path: string): boolean { return true } - // Why: SSH worktrees need the remote URL from the relay-side .git/config - // before local code can map it to a hosted source link. private async readOriginRemoteUrl(worktreePath: string): Promise { try { const result = await this.exec(['remote', 'get-url', 'origin'], worktreePath) @@ -961,7 +146,7 @@ export class SshGitProvider implements IGitProvider { defaultBranch = ref.replace(/^refs\/remotes\/origin\//, '') } } catch { - // Fall back to 'main' + // Fall back to 'main'. } return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts new file mode 100644 index 00000000000..54720d8d9fb --- /dev/null +++ b/src/main/providers/ssh-git-read-provider.ts @@ -0,0 +1,187 @@ +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import type { + GitStagingArea, + GitStatusResult, + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' +import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { GitProviderStatusOptions } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' + +const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const + +export class SshGitReadProvider { + private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() + private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() + + constructor( + private readonly connectionId: string, + protected readonly mux: SshChannelMultiplexer, + private readonly hostPlatform: RemoteHostPlatform | null = null + ) {} + + getConnectionId(): string { + return this.connectionId + } + + getHostPlatform(): RemoteHostPlatform | null { + return this.hostPlatform + } + + protected async runWithGitReadInvalidation(run: () => Promise): Promise { + this.invalidateGitReads() + try { + return await run() + } finally { + this.invalidateGitReads() + } + } + + private invalidateGitReads(): void { + this.gitDiffReadDedupe.clear() + this.statusReadLeaseOwner.invalidate() + this.upstreamStatusReadOwner.invalidate() + } + + async getStatus( + worktreePath: string, + options?: GitProviderStatusOptions + ): Promise { + this.gitDiffReadDedupe.clear() + const request = { + worktreePath, + ...(options?.includeIgnored ? { includeIgnored: true } : {}), + ...(options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(options?.reuseLineStats ? { reuseLineStats: true } : {}), + ...(options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase }) + } + const key = stableInFlightKey([ + worktreePath, + options?.includeIgnored === true, + options?.bypassEffectiveUpstreamNegativeCache === true, + options?.reuseLineStats === true, + options?.branchLineTotalMergeBase ?? '' + ]) + return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { + return (await this.mux.request('git.status', request, { + signal: sharedSignal + })) as GitStatusResult + }) + } + + async getSubmoduleStatus( + worktreePath: string, + submodulePath: string, + area: GitStagingArea = 'unstaged' + ): Promise { + this.gitDiffReadDedupe.clear() + try { + return (await this.mux.request('git.submoduleStatus', { + worktreePath, + submodulePath, + area + })) as GitStatusResult + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + } + throw error + } + } + + async getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), + async () => + (await requestGitStreamable(this.mux, 'git.diff', { + worktreePath, + filePath, + staged, + compareAgainstHead + })) as GitDiffResult + ) as Promise + } + + async getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } + ): Promise { + const keyOptions = options ?? {} + const { headOid: rawHeadOid, ...relayOptions } = keyOptions + const headOid = rawHeadOid == null ? undefined : rawHeadOid + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'branchDiff', + worktreePath, + baseRef, + keyOptions.includePatch ?? null, + keyOptions.filePath ?? null, + keyOptions.oldPath ?? null, + headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid + ]), + async () => + (await requestGitStreamable(this.mux, 'git.branchDiff', { + worktreePath, + baseRef, + ...relayOptions, + ...(headOid === undefined ? {} : { headOid }) + })) as GitDiffResult[] + ) as Promise + } + + async getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'commitDiff', + worktreePath, + args.commitOid, + args.parentOid ?? null, + args.filePath, + args.oldPath ?? null + ]), + async () => + (await requestGitStreamable(this.mux, 'git.commitDiff', { + worktreePath, + ...args + })) as GitDiffResult + ) as Promise + } + + async getUpstreamStatus( + worktreePath: string, + pushTarget?: GitPushTarget + ): Promise { + return this.upstreamStatusReadOwner.read( + { kind: 'ssh-provider' }, + worktreePath, + pushTarget, + async () => + (await this.mux.request('git.upstreamStatus', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + })) as GitUpstreamStatus + ) + } +} diff --git a/src/main/providers/ssh-git-relay-errors.ts b/src/main/providers/ssh-git-relay-errors.ts new file mode 100644 index 00000000000..d8bd8fe9016 --- /dev/null +++ b/src/main/providers/ssh-git-relay-errors.ts @@ -0,0 +1,8 @@ +import { JsonRpcErrorCode } from '../ssh/relay-protocol' + +export function isJsonRpcMethodNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound +} diff --git a/src/main/providers/ssh-git-remote-sync-provider.ts b/src/main/providers/ssh-git-remote-sync-provider.ts new file mode 100644 index 00000000000..bead9414f3b --- /dev/null +++ b/src/main/providers/ssh-git-remote-sync-provider.ts @@ -0,0 +1,84 @@ +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitPushTarget } from '../../shared/worktree/types' +import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' +import { SshGitWorkingTreeProvider } from './ssh-git-working-tree-provider' + +export class SshGitRemoteSyncProvider extends SshGitWorkingTreeProvider { + async pushBranch( + worktreePath: string, + publish = false, + pushTarget?: GitPushTarget, + options: { forceWithLease?: boolean } = {} + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.push', { + worktreePath, + publish, + pushTarget, + ...(options.forceWithLease === true ? { forceWithLease: true } : {}) + }) + }) + } + + async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fastForward', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + }) + }) + } + + async rebaseFromBase(worktreePath: string, baseRef: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request( + 'git.rebaseFromBase', + { worktreePath, baseRef }, + { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } + ) + }) + } + + async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async syncForkDefaultBranch( + worktreePath: string, + expectedUpstream: GitForkSyncExpectedUpstream + ): Promise { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.forkSync', { + worktreePath, + ...(expectedUpstream ? { expectedUpstream } : {}) + })) as GitForkSyncResult + ) + } + + async fetchRemoteTrackingRef( + worktreePath: string, + remote: string, + branch: string, + ref: string, + options?: { skipAutoMaintenance?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetchRemoteTrackingRef', { + worktreePath, + remote, + branch, + ref, + ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) + }) + }) + } +} diff --git a/src/main/providers/ssh-git-review-head-provider.ts b/src/main/providers/ssh-git-review-head-provider.ts new file mode 100644 index 00000000000..5ff5cc69048 --- /dev/null +++ b/src/main/providers/ssh-git-review-head-provider.ts @@ -0,0 +1,70 @@ +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitRemoteSyncProvider } from './ssh-git-remote-sync-provider' + +function readDurableReviewHeadLocalRef( + result: unknown, + kind: 'pull request' | 'merge request' +): string { + if (result && typeof result === 'object' && 'localRef' in result) { + const localRef = (result as { localRef: unknown }).localRef + if (typeof localRef === 'string') { + const trimmed = localRef.trim() + if (trimmed.startsWith('refs/orca/')) { + return trimmed + } + } + } + throw new Error( + `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` + ) +} + +export class SshGitReviewHeadProvider extends SshGitRemoteSyncProvider { + async fetchGitLabMergeRequestHead( + worktreePath: string, + remote: string, + mrIid: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { + worktreePath, + remote, + mrIid + }) + return readDurableReviewHeadLocalRef(result, 'merge request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } + + async fetchGitHubPullRequestHead( + worktreePath: string, + remote: string, + prNumber: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitHubPullRequestHead', { + worktreePath, + remote, + prNumber + }) + return readDurableReviewHeadLocalRef(result, 'pull request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts new file mode 100644 index 00000000000..6942b91ca1b --- /dev/null +++ b/src/main/providers/ssh-git-working-tree-provider.ts @@ -0,0 +1,122 @@ +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { GitConflictOperation } from '../../shared/git-status-types' +import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' + +export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { + async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { + return (await this.mux.request('git.checkIgnored', { + worktreePath, + paths: relativePaths + })) as string[] + } + + async getHistory( + worktreePath: string, + options: GitHistoryOptions = {} + ): Promise { + return (await this.mux.request('git.history', { + worktreePath, + ...options + })) as GitHistoryResult + } + + async commit( + worktreePath: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.commit', { + worktreePath, + message + })) as { success: boolean; error?: string } + ) + } + + async stageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.stage', { worktreePath, filePath }) + }) + } + + async unstageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.unstage', { worktreePath, filePath }) + }) + } + + async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkStage', { worktreePath, filePaths }) + }) + } + + async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) + }) + } + + async discardChanges(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.discard', { worktreePath, filePath }) + }) + } + + async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) + }) + } + + async detectConflictOperation(worktreePath: string): Promise { + return (await this.mux.request('git.conflictOperation', { + worktreePath + })) as GitConflictOperation + } + + async abortMerge(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortMerge', { worktreePath }) + }) + } + + async abortRebase(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortRebase', { worktreePath }) + }) + } + + async checkoutBranch(worktreePath: string, branch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.checkout', { worktreePath, branch }) + }) + } + + async listLocalBranches( + worktreePath: string + ): Promise<{ current: string | null; branches: string[] }> { + return (await this.mux.request('git.localBranches', { worktreePath })) as { + current: string | null + branches: string[] + } + } + + async getBranchCompare(worktreePath: string, baseRef: string): Promise { + return (await this.mux.request('git.branchCompare', { + worktreePath, + baseRef + })) as GitBranchCompareResult + } + + async getCommitCompare(worktreePath: string, commitId: string): Promise { + return (await this.mux.request('git.commitCompare', { + worktreePath, + commitId + })) as GitCommitCompareResult + } +} diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts new file mode 100644 index 00000000000..ce509ccf847 --- /dev/null +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -0,0 +1,143 @@ +import type { GitStatusResult } from '../../shared/git-status-types' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' + +function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { + if (entries.length === 0) { + return undefined + } + return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') +} + +function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { + const trackedLines = (stdout ?? '') + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) + return trackedLines.length > 0 ? trackedLines.join('\n') : undefined +} + +export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { + private loggedWorktreeIsCleanFallback = false + + async listWorktrees( + repoPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'git.listWorktrees', + { repoPath }, + { signal: options?.signal } + )) as GitWorktreeInfo[] + } + + async addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.addWorktree', { + repoPath, + branchName, + targetDir, + ...options + }) + }) + } + + async removeWorktree( + worktreePath: string, + force?: boolean, + options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } + ): Promise { + return this.runWithGitReadInvalidation( + async () => + ((await this.mux.request('git.removeWorktree', { + worktreePath, + force, + ...options + })) ?? {}) as RemoveWorktreeResult + ) + } + + async worktreeIsClean( + worktreePath: string, + options: { includeUntracked?: boolean } = {} + ): Promise<{ clean: boolean; stdout?: string }> { + try { + const result = (await this.mux.request('git.worktreeIsClean', { + worktreePath, + ...(options.includeUntracked === false ? { includeUntracked: false } : {}) + })) as { clean: boolean; stdout?: string } + if (options.includeUntracked === false) { + if (!result.clean && result.stdout === undefined) { + return result + } + const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) + return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } + } + return result + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedWorktreeIsCleanFallback) { + this.loggedWorktreeIsCleanFallback = true + console.warn( + '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' + ) + } + const status = await this.getStatus(worktreePath) + const entries = + options.includeUntracked === false + ? status.entries.filter((entry) => entry.area !== 'untracked') + : status.entries + const clean = entries.length === 0 + return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } + } + } + + async refreshLocalBaseRefForWorktreeCreate(args: { + repoPath: string + fullRef: string + remoteTrackingRef: string + ownerWorktreePath?: string + checkOnly?: boolean + }): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) + }) + } + + async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) + }) + } + + async forceDeletePreservedBranch( + repoPath: string, + branchName: string, + expectedHead: string + ): Promise { + try { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.forceDeletePreservedBranch', { + repoPath, + branchName, + expectedHead + }) + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 34b4cbbdec6..14a7430f997 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -1,7 +1,7 @@ // Pure mapping from a parsed hook event plus its raw body onto the `agent.hook` envelope that goes // over the SSH channel. Sits beside agent-hook-envelope-publication.ts, which owns shedding and // redelivery of the same envelope. -import type { AgentHookEventPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' // Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index b69abcb49a8..bd6d2796163 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -2,14 +2,14 @@ // when the hook fired, so re-read the same body on a timer and re-apply only if it changed. Both // timer families live in one owner so pane teardown and server stop tear both down in one ordered // place before the listener caches are cleared. +import { hasCodexTranscriptSubagents } from '../shared/agent-hook-listener/providers/codex-state' import { - hasCodexTranscriptSubagents, hasPendingAgentResultText, - normalizeHookPayload, - preparePendingGrokResultDiscovery, - type AgentHookEventPayload, - type HookListenerState -} from '../shared/agent-hook-listener' + preparePendingGrokResultDiscovery +} from '../shared/agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index f80de335e43..0e3771aff55 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -3,14 +3,20 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' -import * as agentHookListener from '../shared/agent-hook-listener' +import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +type RelayServerInternals = { + state: { lastStatusByPaneKey: Map } + retryScheduler: AgentHookResultRetryScheduler +} + describe('RelayAgentHookServer', () => { let dir: string beforeEach(() => { @@ -76,6 +82,97 @@ describe('RelayAgentHookServer', () => { } }) + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { + const order: string[] = [] + let server!: RelayAgentHookServer + let internals!: RelayServerInternals + server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + order.push('forward') + } + }) + // Characterization deliberately observes the server-owned scheduler without widening production API. + internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const originalAssistantRetry = retryScheduler.scheduleAssistantMessageRetry.bind(retryScheduler) + const originalCodexRetry = retryScheduler.scheduleCodexSubagentPoll.bind(retryScheduler) + const assistantRetry = vi + .spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'ordered' } + }) + }) + order.push('response') + expect(res.status).toBe(204) + expect(order).toEqual(['forward', 'assistant-retry', 'codex-retry', 'response']) + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + + it('fails open after a throwing forward while retaining the already-cached event', async () => { + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('forward failed') + } + }) + // Characterization deliberately observes cache/scheduler order without widening production API. + const internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const assistantRetry = vi.spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'cached before throw' } + }) + }) + expect(res.status).toBe(204) + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + it('forwards Claude background monitoring until its authoritative inventory drains', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index fb37dc64e3d..84474cbab00 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,8 +1,3 @@ -// Relay-side adapter for the shared agent-hook listener: hosts a loopback HTTP server and -// forwards each parsed payload via a callback so `relay.ts` re-emits it as an `agent.hook` -// JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey: a -// reattaching Orca only needs each pane's current status, never its history, and the bound keeps a -// long-lived relay from growing with every event. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -12,15 +7,17 @@ import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, - getEndpointFileName, - HOOK_REQUEST_SLOWLORIS_MS, - normalizeHookPayload, - readRequestBody, - resolveHookSource, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../shared/agent-hook-listener' +} from '../shared/agent-hook-listener/listener-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../shared/agent-hook-listener/endpoint-publication' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { readRequestBody } from '../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import { createHookTransportInterferenceTracker, describeHookTransportInterference, @@ -37,7 +34,6 @@ import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-schedul export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void -// Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 export type RelayHookServerOptions = { @@ -49,7 +45,6 @@ export type RelayHookServerOptions = { token?: string /** Preferred bind port. WSL relay passes the Windows listener's port so env-sourced client coords stay truthful; falls back to :0 if occupied. Defaults to :0. */ preferredPort?: number - /** Called once per parsed payload; the relay wires this to `dispatcher.notify('agent.hook', envelope)`. */ forward: RelayHookForward } @@ -121,7 +116,6 @@ export class RelayAgentHookServer { } } - /** True when the preferred port was occupied and the server fell back to an ephemeral bind. */ get usedPortFallback(): boolean { return this.portFallbackApplied } diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 57b3466707d..18d837bb4ef 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { execFileSync, spawn as spawnChild } from 'node:child_process' import { build } from 'esbuild' import { spawnRelay, type RelayProcess } from './subprocess-test-utils' -import { getEndpointFileName } from '../shared/agent-hook-listener' +import { getEndpointFileName } from '../shared/agent-hook-listener/endpoint-publication' import { relayTestSocketPath } from './relay-test-socket-path' const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index aed0685dbb7..5047feac381 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' -import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents' +import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -76,7 +76,7 @@ export function useAppSessionPersistence(): void { useEffect(() => { return createSessionWriteSubscriber({ store: useAppStore, - shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), + shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index c211a13581b..e5808aa5459 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -1,79 +1,22 @@ -/* eslint-disable max-lines -- Why: the Linear issue page co-locates the - full-page layout with its hydration/comment state so the selected issue - surface stays coherent with the existing Linear drawer behavior. */ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Linear issue hydration and comments are loaded from provider IPC for the selected issue while preserving edit guards. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - ArrowRight, - Check, - ChevronDown, - ChevronLeft, - ChevronRight, - Clipboard, - Copy, - ExternalLink, - FolderKanban, - FolderOpen, - GitBranch, - LoaderCircle, - Plus, - RefreshCw, - X -} from 'lucide-react' -import { toast } from 'sonner' +import React, { useCallback, useMemo } from 'react' import { VisuallyHidden } from 'radix-ui' -import { LinearIcon } from '@/components/icons/LinearIcon' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { - initLinearIssueEditState, - LinearIssueCommentFooter, - LinearIssueEditSection, - type LinearEditState, - type LinearLocalComment -} from '@/components/LinearItemDrawer' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { buildLinearIssueContextSnapshot } from '@/lib/linear-issue-context-snapshot' import { findLinearIssueWorkspaceAttachment } from '@/lib/linear-issue-workspace-attachment' import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' -import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context' -import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' -import { - buildLinearIssueBranchName, - formatLinearIssueRelativeTime -} from '@/components/linear-issue-workspace-text' -import { getLinearProjectSearchRequestQuery } from '@/components/linear-project-search-query' -import { - linearCreateSubIssue, - linearGetIssue, - linearIssueComments, - linearListProjects, - linearUpdateIssue -} from '@/runtime/runtime-linear-client' -import type { - LinearComment, - LinearIssue, - LinearIssueChildSummary -} from '../../../shared/linear/issue-types' -import type { LinearProjectSummary } from '../../../shared/linear/project-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' +import { LinearIssueActivity } from './linear-issue-activity' +import { LinearIssueSubIssues } from './linear-issue-sub-issues' +import { useLinearIssueWorkspaceDetail } from './linear-issue-workspace-detail-state' +import { LinearIssueWorkspaceHeader } from './linear-issue-workspace-header' +import { LinearIssueWorkspaceSidebar } from './linear-issue-workspace-sidebar' type LinearIssueWorkspaceProps = { issue: LinearIssue | null @@ -85,433 +28,134 @@ type LinearIssueWorkspaceProps = { sourceContext?: TaskSourceContext | null } -async function copyTextToClipboard(text: string, label: string): Promise { - try { - await window.api.ui.writeClipboardText(text) - toast.success( - translate('auto.components.LinearIssueWorkspace.7835483c43', '{{value0}} copied', { - value0: label - }) - ) - } catch { - toast.error( - translate('auto.components.LinearIssueWorkspace.9bcbaa2737', 'Failed to copy {{value0}}', { - value0: label.toLowerCase() - }) - ) - } +type LinearIssueWorkspaceIssueProps = Omit< + LinearIssueWorkspaceProps, + 'issue' | 'variant' | 'backLabel' +> & { + issue: LinearIssue + variant: 'sheet' | 'page' + backLabel: string + requestKey: string } -function LinearIssueAvatar({ - avatarUrl, - name, - className = 'size-6' -}: { - avatarUrl?: string - name?: string - className?: string -}): React.JSX.Element { - if (avatarUrl) { - return {name - } - - const initial = name?.trim().charAt(0).toUpperCase() || '?' - return ( - - ) -} - -function LinearIssueSubIssueButton({ +function LinearIssueWorkspaceIssue({ issue, + onUse, onOpenIssue, - sourceContext -}: { - issue: LinearIssue - onOpenIssue: (issue: LinearIssue) => void - sourceContext?: TaskSourceContext | null -}): React.JSX.Element { - const settings = useAppStore((s) => s.settings) + onClose, + variant, + backLabel, + sourceContext, + requestKey +}: LinearIssueWorkspaceIssueProps): React.JSX.Element { + const settings = useAppStore((state) => state.settings) const providerSettings = sourceContext ?? settings - const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) - const [open, setOpen] = useState(false) - const [title, setTitle] = useState('') - const [optimisticSubIssues, setOptimisticSubIssues] = useState<{ - issueId: string - subIssues: LinearIssueChildSummary[] - }>(() => ({ - issueId: issue.id, - subIssues: [] - })) - const [submitting, setSubmitting] = useState(false) - const [openingSubIssueId, setOpeningSubIssueId] = useState(null) - const mountedRef = useMountedRef() - - const subIssues = useMemo(() => { - const baseSubIssues = issue.subIssues ?? [] - if (optimisticSubIssues.issueId !== issue.id || optimisticSubIssues.subIssues.length === 0) { - return baseSubIssues - } - const baseIds = new Set(baseSubIssues.map((subIssue) => subIssue.id)) - const additions = optimisticSubIssues.subIssues.filter((subIssue) => !baseIds.has(subIssue.id)) - return additions.length === 0 ? baseSubIssues : [...baseSubIssues, ...additions] - }, [issue.id, issue.subIssues, optimisticSubIssues]) - - const handleOpenSubIssue = useCallback( - async (subIssue: LinearIssueChildSummary) => { - setOpeningSubIssueId(subIssue.id) - try { - const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId, { - sourceContext - }) - if (!mountedRef.current) { - return - } - if (fullIssue) { - onOpenIssue(fullIssue) - } else { - toast.error( - translate('auto.components.LinearIssueWorkspace.9a1317cdd3', 'Failed to load sub-issue') - ) - } - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.9a1317cdd3', - 'Failed to load sub-issue' - ) - ) - } - } finally { - if (mountedRef.current) { - setOpeningSubIssueId(null) - } - } - }, - [fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue, sourceContext] + const allWorktrees = useAllWorktrees() + const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const attachmentWorkspaces = useMemo( + () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], + [allWorktrees, folderWorkspaces] ) + const detail = useLinearIssueWorkspaceDetail({ issue, providerSettings, requestKey }) + const attachedWorkspace = useMemo( + () => findLinearIssueWorkspaceAttachment(attachmentWorkspaces, detail.displayed), + [attachmentWorkspaces, detail.displayed] + ) + const attachedWorkspaceLabel = attachedWorkspace + ? getWorktreeAttachmentLabel(attachedWorkspace) + : null - const handleCreate = useCallback(async () => { - const trimmed = title.trim() - if (!trimmed) { - return - } - setSubmitting(true) - try { - const result = await linearCreateSubIssue(providerSettings, { - parentIssueId: issue.id, - teamId: issue.team.id, - title: trimmed, - workspaceId: issue.workspaceId, - projectId: issue.project?.id ?? null - }) - if (result.ok) { - const child = { - id: result.id, - identifier: result.identifier, - title: result.title || trimmed, - url: result.url - } - setOptimisticSubIssues((current) => { - const currentSubIssues = current.issueId === issue.id ? current.subIssues : [] - if ( - currentSubIssues.some((subIssue) => subIssue.id === child.id) || - issue.subIssues?.some((subIssue) => subIssue.id === child.id) - ) { - return current - } - return { issueId: issue.id, subIssues: [...currentSubIssues, child] } - }) - toast.success( - translate('auto.components.LinearIssueWorkspace.aeed19d003', 'Created {{value0}}', { - value0: result.identifier - }) - ) - setTitle('') - setOpen(false) - } else { - toast.error(result.error) - } - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.b25e453c9d', - 'Failed to create sub-issue' - ) - ) - } finally { - setSubmitting(false) - } - }, [ - issue.id, - issue.project?.id, - issue.subIssues, - issue.team.id, - issue.workspaceId, - providerSettings, - title - ]) + const handleUseIssue = useCallback((): void => { + onUse(detail.displayed) + }, [detail.displayed, onUse]) - return ( -
- {subIssues.length > 0 ? ( -
- {subIssues.map((subIssue) => ( - - ))} + const handleOpenOrUseIssue = useCallback((): void => { + openLinearIssueWorkspaceOrStart(detail.displayed, () => onUse(detail.displayed)) + }, [detail.displayed, onUse]) + + const content = ( +
+ +
+
+
+ + + +
+
- ) : null} - - - - - - -
- setTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - void handleCreate() - } - }} - placeholder={translate( - 'auto.components.LinearIssueWorkspace.c182e02de5', - 'Sub-issue title' - )} - className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> -
- -
-
-
-
-
+ + ) -} -function LinearIssueSidebarProjectCard({ - issue, - onProjectChanged, - sourceContext -}: { - issue: LinearIssue - onProjectChanged: (project: LinearProjectSummary) => void - sourceContext?: TaskSourceContext | null -}): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings - const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [projects, setProjects] = useState([]) - const [loading, setLoading] = useState(false) - const [savingProjectId, setSavingProjectId] = useState(null) - - useEffect(() => { - if (!open) { - return - } - const requestQuery = getLinearProjectSearchRequestQuery(query) - if (requestQuery === null) { - // Oversized pasted queries stay visible but must not reach provider IPC/RPC. - setProjects([]) - setLoading(false) - return - } - let cancelled = false - const timeout = window.setTimeout(() => { - setLoading(true) - void linearListProjects(providerSettings, requestQuery, 20, issue.workspaceId) - .then((result) => { - if (!cancelled) { - setProjects(result.items) - } - }) - .catch((error) => { - if (!cancelled) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.38b80780c2', - 'Failed to load projects' - ) - ) - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) - }, 150) - return () => { - cancelled = true - window.clearTimeout(timeout) - } - }, [issue.workspaceId, open, providerSettings, query]) - - const handleSelectProject = useCallback( - async (project: LinearProjectSummary) => { - setSavingProjectId(project.id) - try { - const result = await linearUpdateIssue( - providerSettings, - issue.id, - { projectId: project.id }, - issue.workspaceId - ) - if (result.ok) { - onProjectChanged(project) - patchLinearIssue(issue.id, { project }, { sourceContext }) - toast.success( - translate('auto.components.LinearIssueWorkspace.f9d4ef9807', 'Project updated') - ) - setOpen(false) - } else { - toast.error(result.error) - } - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.8b5b593053', - 'Failed to update project' - ) - ) - } finally { - setSavingProjectId(null) - } - }, - [ - issue.id, - issue.workspaceId, - onProjectChanged, - patchLinearIssue, - providerSettings, - sourceContext - ] - ) + if (variant === 'page') { + return ( +
+ {content} +
+ ) + } return ( -
-
- {translate('auto.components.LinearIssueWorkspace.b51276c8d6', 'Project')} - -
- - - - - -
- setQuery(event.target.value)} - placeholder={translate( - 'auto.components.LinearIssueWorkspace.db3f269d98', - 'Search projects' - )} - className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> -
- {loading ? ( -
- - {translate('auto.components.LinearIssueWorkspace.937ba6ad9a', 'Loading projects')} -
- ) : projects.length > 0 ? ( - projects.map((project) => ( - - )) - ) : ( -
- {query.trim() - ? translate( - 'auto.components.LinearIssueWorkspace.c11b4e3cc2', - 'No projects found.' - ) - : translate( - 'auto.components.LinearIssueWorkspace.76ffd3c937', - 'Search for a project to add.' - )} -
- )} -
-
-
-
-
+ !open && onClose()}> + event.preventDefault()} + > + + + {detail.displayed.title ?? + translate('auto.components.LinearIssueWorkspace.61f424f8ca', 'Linear issue')} + + + + + {translate( + 'auto.components.LinearIssueWorkspace.ad5dec37b7', + 'Preview, edit, and start work from the selected issue.' + )} + + + {content} + + ) } @@ -524,605 +168,27 @@ export default function LinearIssueWorkspace({ backLabel = 'Back', sourceContext }: LinearIssueWorkspaceProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings - const allWorktrees = useAllWorktrees() - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) - const attachmentWorkspaces = useMemo( - () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], - [allWorktrees, folderWorkspaces] - ) - const [fullIssue, setFullIssue] = useState(null) - const [issueLoading, setIssueLoading] = useState(false) - const [comments, setComments] = useState([]) - const [commentsLoading, setCommentsLoading] = useState(false) - const [commentsError, setCommentsError] = useState(null) - const [editState, setEditState] = useState(null) - const requestIdRef = useRef(0) - const hydratedIssueKeyRef = useRef(null) - const hasEditedRef = useRef(false) - const optimisticCommentsRef = useRef([]) - const mountedRef = useMountedRef() - - const handleEditStateChange = useCallback((patch: Partial) => { - hasEditedRef.current = true - setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev)) - setEditState((prev) => (prev ? { ...prev, ...patch } : prev)) - }, []) - - const handleIssueTextChange = useCallback( - (patch: Partial>) => { - hasEditedRef.current = true - setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev)) - }, - [] - ) - - const loadComments = useCallback( - async (targetIssue: LinearIssue, requestId: number): Promise => { - if (mountedRef.current) { - setCommentsLoading(true) - setCommentsError(null) - } - try { - let fetched = (await linearIssueComments( - providerSettings, - targetIssue.id, - targetIssue.workspaceId - )) as LinearComment[] - if (!mountedRef.current || requestId !== requestIdRef.current) { - return - } - const optimistic = optimisticCommentsRef.current - if (optimistic.length > 0) { - const fetchedIds = new Set(fetched.map((comment) => comment.id)) - fetched = [...fetched, ...optimistic.filter((comment) => !fetchedIds.has(comment.id))] - } - setComments(fetched) - } catch (error) { - if (mountedRef.current && requestId === requestIdRef.current) { - setCommentsError(error instanceof Error ? error.message : 'Failed to load comments.') - } - } finally { - if (mountedRef.current && requestId === requestIdRef.current) { - setCommentsLoading(false) - } - } - }, - [mountedRef, providerSettings] - ) - - useEffect(() => { - if (!issue) { - hydratedIssueKeyRef.current = null - setFullIssue(null) - setIssueLoading(false) - setComments([]) - setCommentsError(null) - setEditState(null) - hasEditedRef.current = false - optimisticCommentsRef.current = [] - return + const settings = useAppStore((state) => state.settings) + if (!issue) { + if (variant === 'page') { + return ( +
+ ) } - - const issueKey = `${sourceContext?.hostId ?? settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` - if (hydratedIssueKeyRef.current === issueKey) { - return - } - hydratedIssueKeyRef.current = issueKey - - requestIdRef.current += 1 - const requestId = requestIdRef.current - hasEditedRef.current = false - optimisticCommentsRef.current = [] - setFullIssue(issue) - setEditState(initLinearIssueEditState(issue)) - setComments([]) - setCommentsError(null) - setIssueLoading(true) - - // Why: issue hydration and comments are separate surfaces; a comments - // failure should not blank the issue detail the user selected. - void linearGetIssue(providerSettings, issue.id, issue.workspaceId) - .then((issueResult) => { - if (!mountedRef.current || requestId !== requestIdRef.current) { - return - } - if (issueResult) { - const fetched = issueResult as LinearIssue - setFullIssue((prev) => { - if (!hasEditedRef.current || !prev) { - return fetched - } - // Why: late hydration can carry pre-edit field data; keep the - // optimistic fields while accepting fresh non-field detail data. - return { - ...fetched, - state: prev.state, - title: prev.title, - description: prev.description, - priority: prev.priority, - assignee: prev.assignee, - estimate: prev.estimate, - labelIds: prev.labelIds, - labels: prev.labels - } - }) - if (!hasEditedRef.current) { - setEditState(initLinearIssueEditState(fetched)) - } - } - }) - .catch(() => { - /* The list issue remains useful if detail hydration is temporarily unavailable. */ - }) - .finally(() => { - if (mountedRef.current && requestId === requestIdRef.current) { - setIssueLoading(false) - } - }) - - void loadComments(issue, requestId) - }, [issue, loadComments, mountedRef, providerSettings, settings, sourceContext?.hostId]) - - const displayed = fullIssue ?? issue - - const attachedWorkspace = useMemo( - () => (displayed ? findLinearIssueWorkspaceAttachment(attachmentWorkspaces, displayed) : null), - [attachmentWorkspaces, displayed] - ) - const attachedWorkspaceLabel = attachedWorkspace - ? getWorktreeAttachmentLabel(attachedWorkspace) - : null - - const handleUseIssue = useCallback((): void => { - if (!displayed) { - return - } - onUse(displayed) - }, [displayed, onUse]) - - const handleOpenOrUseIssue = useCallback((): void => { - if (!displayed) { - return - } - openLinearIssueWorkspaceOrStart(displayed, () => onUse(displayed)) - }, [displayed, onUse]) - - const handleCommentAdded = useCallback((comment: LinearLocalComment) => { - const newComment: LinearComment = { - id: comment.id || createBrowserUuid(), - body: comment.body, - createdAt: comment.createdAt, - user: { displayName: 'You' } - } - optimisticCommentsRef.current.push(newComment) - setComments((prev) => [...prev, newComment]) - }, []) - - const handleProjectChanged = useCallback((project: LinearProjectSummary) => { - setFullIssue((prev) => (prev ? { ...prev, project } : prev)) - }, []) - - const actionItems = useMemo(() => { - if (!displayed) { - return [] - } - return [ - { - label: translate('auto.components.LinearIssueWorkspace.9a9a884236', 'Copy URL'), - icon: Clipboard, - action: () => void copyTextToClipboard(displayed.url, 'URL') - }, - { - label: translate('auto.components.LinearIssueWorkspace.30c1242f3a', 'Copy identifier'), - icon: Clipboard, - action: () => void copyTextToClipboard(displayed.identifier, 'Identifier') - }, - { - label: translate( - 'auto.components.LinearIssueWorkspace.5d670ec8dc', - 'Copy suggested branch name' - ), - icon: GitBranch, - action: () => - void copyTextToClipboard(buildLinearIssueBranchName(displayed), 'Suggested branch name') - }, - { - label: translate('auto.components.LinearIssueWorkspace.f6c6381593', 'Copy prompt'), - icon: Clipboard, - action: () => { - const renderedText = buildLinearIssueContextSnapshot(displayed, comments) - const prompt = - buildContainedLinkedContextBlock({ - provider: 'linear', - version: 1, - renderedText - }) ?? renderedText - void copyTextToClipboard(prompt, 'Prompt') - } - } - ] - }, [comments, displayed]) - - const content = displayed ? ( -
-
-
- {variant === 'page' ? ( - - ) : null} - - - {displayed.workspaceName ?? - translate('auto.components.LinearIssueWorkspace.65239a714b', 'Linear')} - - - - {translate('auto.components.LinearIssueWorkspace.f63ef94ea8', 'Issues')} - - - {displayed.identifier} - {displayed.title} - {issueLoading ? : null} -
-
- 2 / 17 - - - - - - {translate('auto.components.LinearIssueWorkspace.9a9a884236', 'Copy URL')} - - - - - - - - {translate('auto.components.LinearIssueWorkspace.openOnLinear', 'Open on Linear')} - - - {attachedWorkspace ? ( - - - - - - - - - - - {translate( - 'auto.components.LinearIssueWorkspace.startNewWorkspace', - 'Start new workspace' - )} - - - - ) : ( - - )} - {variant === 'sheet' ? ( - - - - - - {translate('auto.components.LinearIssueWorkspace.df4c86ed12', 'Close')} - - - ) : null} -
-
- -
-
-
- - - - -
-
-

- {translate('auto.components.LinearIssueWorkspace.543970c87a', 'Activity')} -

-
- -
-
- -
- - - {displayed.assignee?.displayName ?? - translate('auto.components.LinearIssueWorkspace.8a33c85e9c', 'Someone')}{' '} - {translate( - 'auto.components.LinearIssueWorkspace.fabbd3f974', - 'updated the issue ·' - )}{' '} - {formatLinearIssueRelativeTime(displayed.updatedAt)} - -
- - {commentsError ? ( -
- {commentsError} - -
- ) : null} - - {commentsLoading && comments.length === 0 ? ( -
- -
- ) : comments.length > 0 ? ( -
- {comments.map((comment) => ( -
- -
-
- - {comment.user?.displayName ?? - translate( - 'auto.components.LinearIssueWorkspace.ca8778c124', - 'Unknown' - )} - - - {formatLinearIssueRelativeTime(comment.createdAt)} - -
-
- -
-
-
- ))} -
- ) : null} - - -
-
- - -
-
-
- ) : null - - if (variant === 'page') { - return ( -
- {content} -
- ) + return !open && onClose()} /> } - + const requestKey = `${sourceContext?.hostId ?? settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` return ( - !open && onClose()}> - { - event.preventDefault() - }} - > - - - {displayed?.title ?? - translate('auto.components.LinearIssueWorkspace.61f424f8ca', 'Linear issue')} - - - - - {translate( - 'auto.components.LinearIssueWorkspace.ad5dec37b7', - 'Preview, edit, and start work from the selected issue.' - )} - - - - {content} - - + ) } diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index d5103ed30fc..a12146e9754 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -5,6 +5,10 @@ import type { ChangelogData, UpdateStatus } from '../../../shared/update-status- import { createUISlice } from '../store/slices/ui' import type { AppState } from '../store/types' import { isHttp2ProtocolError } from './UpdateCard' +import { + getUpdateCardAriaLabel, + isUpdateCardVisible +} from './maintenance/update-card/update-card-visibility' // ── Helpers ────────────────────────────────────────────────────────── @@ -319,35 +323,13 @@ type VisibilityInput = { type VisibilityResult = 'hidden' | 'visible' -/** Mirrors the visibility gates in UpdateCard's render path. */ function computeVisibility(input: VisibilityInput): VisibilityResult { - const { status, dismissedVersion, cachedVersion, hasStartedDownload } = input - const isUserInitiated = 'userInitiated' in status && status.userInitiated - const updateUserInitiatedCycle = input.updateUserInitiatedCycle ?? false - const shouldShowDetailedErrorCard = - status.state === 'error' && (hasStartedDownload || cachedVersion !== null) - - if (status.state === 'checking' && !isUserInitiated) { - return 'hidden' - } - if (status.state === 'not-available' && !isUserInitiated) { - return 'hidden' - } - if (status.state === 'idle') { - return 'hidden' - } - if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) { - return 'hidden' - } - - const effectiveVersion = 'version' in status ? status.version : cachedVersion - if (effectiveVersion && dismissedVersion === effectiveVersion && !updateUserInitiatedCycle) { - if (status.state !== 'downloading' && status.state !== 'error') { - return 'hidden' - } - } - - return 'visible' + return isUpdateCardVisible({ + ...input, + updateUserInitiatedCycle: input.updateUserInitiatedCycle ?? false + }) + ? 'visible' + : 'hidden' } describe('UpdateCard visibility gates', () => { @@ -362,6 +344,10 @@ describe('UpdateCard visibility gates', () => { ).toBe('hidden') }) + it('uses the generic accessible label on idle', () => { + expect(getUpdateCardAriaLabel({ state: 'idle' })).toBe('Update status') + }) + it('hides background checking (not user-initiated)', () => { expect( computeVisibility({ diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index bb2b2d194c3..4bbf47fd492 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -1,94 +1,31 @@ -/* eslint-disable max-lines -- Why: keeps the updater state machine and its presentation variants in one file. */ import { useCallback, useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import type { ChangelogData } from '../../../shared/update-status-types' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { useAppStore } from '../store' import { Card } from './ui/card' import { Button } from './ui/button' -import { Progress } from './ui/progress' -import { AlertCircle, Check, Loader2, Minus, X } from 'lucide-react' -import type { ChangelogData } from '../../../shared/update-status-types' -import { UpdateErrorCardContent, type UpdateErrorCardModel } from './UpdateErrorCardContent' -import { LinuxPackageInstallRecoveryCard } from './LinuxPackageInstallRecoveryCard' -import { - isWindowsSignatureCheckUnavailableFailure, - isWindowsSignatureMismatchFailure -} from '../../../shared/updater-windows-signature-check' -import { getReleaseNotesUrlForVersion } from '../../../shared/release-channel' import { translate } from '@/i18n/i18n' +import { UpdateCardStateContent } from './maintenance/update-card/UpdateCardStateContent' +import { buildUpdateCardErrorModel } from './maintenance/update-card/update-card-error-model' +import { + getUpdateCardAriaLabel, + isHttp2ProtocolError, + isUpdateCardVisible +} from './maintenance/update-card/update-card-visibility' -// ── Helpers ────────────────────────────────────────────────────────── +export { isHttp2ProtocolError } -function isAnimatedGif(url: string | undefined): boolean { - return typeof url === 'string' && url.toLowerCase().endsWith('.gif') -} - -export function isHttp2ProtocolError(message: string): boolean { - const normalized = message.toLowerCase() - return ( - normalized.includes('err_http2_protocol_error') || - normalized.includes('http2_protocol_error') || - (normalized.includes('http/2') && normalized.includes('protocol')) - ) -} - -// ── Compact card (transient check feedback) ───────────────────────── - -function CompactCardContent({ - icon, - text, - onClose, - action -}: { - icon: 'spinner' | 'check' | 'error' - text: string - onClose?: () => void - action?: { label: string; url: string } -}) { - return ( -
-
- {icon === 'spinner' && } - {icon === 'check' && } - {icon === 'error' && } -
-
-

{text}

- {action && ( - - )} -
- {onClose && ( - - )} -
- ) -} - -// ── Main component ────────────────────────────────────────────────── - -export function UpdateCard() { - const status = useAppStore((s) => s.updateStatus) - const storeChangelog = useAppStore((s) => s.updateChangelog) - const updateUserInitiatedCycle = useAppStore((s) => s.updateUserInitiatedCycle) - const dismissedVersion = useAppStore((s) => s.dismissedUpdateVersion) - const dismissUpdate = useAppStore((s) => s.dismissUpdate) - const collapsed = useAppStore((s) => s.updateCardCollapsed) - const setCollapsed = useAppStore((s) => s.setUpdateCardCollapsed) - const reassuranceSeen = useAppStore((s) => s.updateReassuranceSeen) - const markReassuranceSeen = useAppStore((s) => s.markUpdateReassuranceSeen) +export function UpdateCard(): React.JSX.Element | null { + const status = useAppStore((state) => state.updateStatus) + const changelog: ChangelogData | null = useAppStore((state) => state.updateChangelog) + const updateUserInitiatedCycle = useAppStore((state) => state.updateUserInitiatedCycle) + const dismissedVersion = useAppStore((state) => state.dismissedUpdateVersion) + const dismissUpdate = useAppStore((state) => state.dismissUpdate) + const collapsed = useAppStore((state) => state.updateCardCollapsed) + const setCollapsed = useAppStore((state) => state.setUpdateCardCollapsed) + const reassuranceSeen = useAppStore((state) => state.updateReassuranceSeen) + const markReassuranceSeen = useAppStore((state) => state.markUpdateReassuranceSeen) const hasStartedDownload = useRef(false) const dismissAnimationTimerRef = useRef(null) const collapseAnimationTimerRef = useRef(null) @@ -97,16 +34,10 @@ export function UpdateCard() { const [installError, setInstallError] = useState(null) const [compatibilityRelaunching, setCompatibilityRelaunching] = useState(false) const [compatibilitySetupError, setCompatibilitySetupError] = useState(null) - // Why: the dismiss gate keeps error cards visible, so a separate local flag tracks the error card's own X close. const [errorDismissed, setErrorDismissed] = useState(false) - // Why: local flag (not store) for the transient "up to date" auto-dismiss — no other component needs it. const [autoDismissed, setAutoDismissed] = useState(false) - // Tracks card exit so the fade-out animation plays before unmount. const [exiting, setExiting] = useState(false) - const changelog: ChangelogData | null = storeChangelog const isLocalBuild = status.source === 'local' - - // Why: the 'error' variant carries no version, but the card needs it for the fallback URL and dismiss; cache from states that have it. const versionRef = useRef(null) if ('version' in status && status.version) { versionRef.current = status.version @@ -115,11 +46,8 @@ export function UpdateCard() { status.state === 'idle' || status.state === 'not-available' ) { - // Why: clear the cached version so a later check failure can't link/dismiss against an unrelated older release. versionRef.current = null } - - // Why: reset component-local state on a new version so stale flags (media load, hasStartedDownload) don't leak forward. const prevVersionRef = useRef(null) if (status.state === 'available' && status.version !== prevVersionRef.current) { prevVersionRef.current = status.version @@ -128,8 +56,6 @@ export function UpdateCard() { setMediaLoaded(false) setInstallError(null) } - - // Why: reset per-cycle flags when a new status arrives so the card shows again next check cycle. const prevStateRef = useRef(status.state) if (status.state !== prevStateRef.current) { prevStateRef.current = status.state @@ -146,18 +72,13 @@ export function UpdateCard() { const shouldAutoDismissLatest = status.state === 'not-available' && 'userInitiated' in status && Boolean(status.userInitiated) - - // Auto-dismiss "You're on the latest version" after 3s; timer resets if status changes first. useEffect(() => { if (!shouldAutoDismissLatest) { return } - const timer = setTimeout(() => setAutoDismissed(true), 3000) - return () => clearTimeout(timer) + const timer = window.setTimeout(() => setAutoDismissed(true), 3000) + return () => window.clearTimeout(timer) }, [shouldAutoDismissLatest]) - - // Why: quitAndInstall must run in an effect, not render — StrictMode's double render would fire it twice. - // Gated on hasStartedDownload so Settings-initiated downloads don't auto-restart (user expects "Restart" there). useEffect(() => { if (status.state === 'downloaded' && hasStartedDownload.current) { void window.api.updater.quitAndInstall().catch((error) => { @@ -166,9 +87,7 @@ export function UpdateCard() { } }, [status.state]) - // ── Prefers-reduced-motion ────────────────────────────────────────── const prefersReducedMotion = usePrefersReducedMotion() - const clearAnimationTimers = useCallback(() => { if (dismissAnimationTimerRef.current !== null) { window.clearTimeout(dismissAnimationTimerRef.current) @@ -179,81 +98,38 @@ export function UpdateCard() { collapseAnimationTimerRef.current = null } }, []) - const cardRootRef = useCallback( (node: HTMLDivElement | null) => { - if (node !== null) { - return + if (node === null) { + clearAnimationTimers() } - // Why: cancel exit timers when the card surface unmounts so stale callbacks don't fire. - clearAnimationTimers() }, [clearAnimationTimers] ) - - // ── Visibility gates ────────────────────────────────────────────── - - const isUserInitiated = 'userInitiated' in status && status.userInitiated const cachedVersion = versionRef.current - const shouldShowDetailedErrorCard = - status.state === 'error' && (hasStartedDownload.current || cachedVersion !== null) - - // Compact transient states: only show for user-initiated checks. - if (status.state === 'checking' && !isUserInitiated) { - return null - } - if (status.state === 'not-available' && !isUserInitiated) { - return null - } - if (status.state === 'not-available' && autoDismissed) { - return null - } - - // Background states that never show the card. - if (status.state === 'idle') { - return null - } - - // Error: show for user-initiated failures or failures tied to a cached version; background failures stay silent. - if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) { - return null - } - - // Why: the dismiss gate below keeps error cards visible, so an explicit X on the error card needs this gate to hide it. - if (status.state === 'error' && errorDismissed) { - return null - } - - // Dismiss gate: hide previously-dismissed versions for passive states, keep in-progress/error visible, and bypass for user-initiated checks. - if (versionRef.current && dismissedVersion === versionRef.current && !updateUserInitiatedCycle) { - if (status.state !== 'downloading' && status.state !== 'error') { - return null - } - } - if ( - collapsed && - (status.state === 'downloading' || status.state === 'downloaded' || status.state === 'error') + !isUpdateCardVisible({ + status, + dismissedVersion, + cachedVersion, + hasStartedDownload: hasStartedDownload.current, + updateUserInitiatedCycle, + autoDismissed, + errorDismissed, + collapsed + }) ) { return null } - // ── Shared helpers ──────────────────────────────────────────────── - - const isRichMode = changelog?.release != null - - const handleUpdate = () => { + const handleUpdate = (): void => { hasStartedDownload.current = true - // Why: clicking Update implies the user isn't worried about interruption, so retire the reassurance tip. if (!reassuranceSeen) { markReassuranceSeen() } void window.api.updater.download() } - - // Why: the 'error' variant has no version field, so dismiss needs an explicit version override. - const handleClose = () => { - // Why: dismissUpdate clears the store manual-check bypass so the dismiss gate re-engages after closing. + const handleClose = (): void => { if (status.state === 'error') { setErrorDismissed(true) if (cachedVersion) { @@ -263,16 +139,12 @@ export function UpdateCard() { } dismissUpdate() } - - const handleInstallRetry = () => { + const handleInstallRetry = (): void => { void window.api.updater.quitAndInstall().catch((error) => { setInstallError(String((error as Error)?.message ?? error)) }) } - - const handleEnableHttp1Compatibility = () => { - // Why: the shared error card marks a pending action aria-disabled rather than disabled, so the - // second click of a double-click now reaches this handler and would relaunch twice. + const handleEnableHttp1Compatibility = (): void => { if (compatibilityRelaunching) { return } @@ -288,132 +160,25 @@ export function UpdateCard() { setCompatibilityRelaunching(false) }) } - - // Why: order matters — the wrong-publisher security-stop must beat the "check couldn't run" case so integrity failures aren't softened to "try again". - const isHttp2UpdateError = status.state === 'error' && isHttp2ProtocolError(status.message) - const isSignatureMismatchError = - status.state === 'error' && isWindowsSignatureMismatchFailure(status.message) - const isSignatureCheckBlockedError = - status.state === 'error' && isWindowsSignatureCheckUnavailableFailure(status.message) - // Carries the diagnostic alongside the recovery so the render branch needs no second state check. + const errorCard = buildUpdateCardErrorModel({ + status, + isLocalBuild, + cachedVersion, + installError, + compatibilityRelaunching, + compatibilitySetupError, + onChooseLocalBuild: () => void window.api.updater.check({ localBuild: true }), + onEnableHttp1Compatibility: handleEnableHttp1Compatibility, + onRetryDownload: handleUpdate, + onRecheck: () => void window.api.updater.check({ includePrerelease: false }), + onInstallRetry: handleInstallRetry + }) const linuxPackageRecovery = status.state === 'error' && status.recovery?.kind === 'linux-package-install' ? { recovery: status.recovery, diagnostic: status.message } : null - const errorCard: UpdateErrorCardModel | null = - status.state === 'error' - ? isLocalBuild - ? { - title: cachedVersion - ? translate('auto.components.UpdateCard.8cf17b10af', 'Local Build Error') - : translate('auto.components.UpdateCard.a4650b0dc4', 'Could Not Use Local Build'), - summary: cachedVersion - ? translate( - 'auto.components.UpdateCard.b1e390250d', - 'Could not complete the local build switch.' - ) - : translate( - 'auto.components.UpdateCard.d29740d175', - 'The selected build could not be used.' - ), - detail: status.message, - primaryAction: { - label: translate('auto.components.UpdateCard.37d45c9ec1', 'Choose Another Build'), - onClick: () => { - void window.api.updater.check({ localBuild: true }) - } - } - } - : isHttp2UpdateError - ? { - variant: 'http1Compatibility', - title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), - summary: 'Orca can retry through HTTP/1.1 compatibility mode.', - explainer: translate( - 'auto.components.UpdateCard.90559b14e3', - 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' - ), - detail: compatibilitySetupError ?? status.message, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), - pendingLabel: 'Restarting...', - isPending: compatibilityRelaunching, - onClick: handleEnableHttp1Compatibility - } - } - : isSignatureMismatchError - ? { - // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path. - variant: 'security', - title: translate( - 'auto.components.UpdateCard.5b309b19f3', - "Update Wasn't Installed" - ), - summary: translate( - 'auto.components.UpdateCard.092f09fc14', - "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version." - ), - detail: status.message, - // Why: linking the rejected version would let users bypass the publisher check by re-running it. - releaseUrl: getReleaseNotesUrlForVersion(null), - manualLabel: translate( - 'auto.components.UpdateCard.c9ff9b9ec2', - 'Check official releases' - ) - } - : isSignatureCheckBlockedError - ? { - title: translate( - 'auto.components.UpdateCard.e944c2de43', - 'Update Verification Blocked' - ), - summary: translate( - 'auto.components.UpdateCard.a05992a26b', - "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases." - ), - detail: status.message, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate - } - } - : { - // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug. - title: cachedVersion ? 'Update Error' : 'Update Check Failed', - summary: cachedVersion - ? 'Could not complete the update.' - : 'Could not check for updates.', - detail: status.message, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - // Why: check-time failures are often transient, so offer a Re-check instead of forcing manual download. - primaryAction: cachedVersion - ? { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate - } - : { - label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), - onClick: () => { - void window.api.updater.check({ includePrerelease: false }) - } - } - } - : installError - ? { - title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), - summary: 'Could not restart to install the update.', - detail: installError, - releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'), - onClick: handleInstallRetry - } - } - : null - const handleDismissWithAnimation = () => { + const handleDismissWithAnimation = (): void => { if (prefersReducedMotion) { handleClose() return @@ -427,9 +192,7 @@ export function UpdateCard() { handleClose() }, 150) } - - // Why: dismissing an active download would orphan it, so long-running phases minimize to the status bar. - const handleCollapseWithAnimation = () => { + const handleCollapseWithAnimation = (): void => { if (prefersReducedMotion) { setCollapsed(true) return @@ -444,12 +207,11 @@ export function UpdateCard() { setExiting(false) }, 150) } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== 'Escape') { + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key !== 'Escape') { return } - e.preventDefault() + event.preventDefault() if ( status.state === 'downloading' || status.state === 'downloaded' || @@ -461,157 +223,38 @@ export function UpdateCard() { } } - // ── Dynamic aria-label ──────────────────────────────────────────── - - const ariaLabel = - status.state === 'checking' - ? 'Checking for updates' - : status.state === 'not-available' - ? "You're on the latest version" - : status.state === 'available' - ? 'Update available' - : status.state === 'downloading' - ? 'Downloading update' - : status.state === 'downloaded' - ? 'Update ready to install' - : status.state === 'error' - ? 'Update error' - : 'Update status' - - // ── Card wrapper ────────────────────────────────────────────────── + const cardContent = ( + setMediaFailed(true)} + onMediaLoad={() => setMediaLoaded(true)} + onUpdate={handleUpdate} + onInstallRetry={handleInstallRetry} + onDismiss={handleDismissWithAnimation} + onCollapse={handleCollapseWithAnimation} + /> + ) const animationClass = prefersReducedMotion ? '' : exiting ? 'animate-update-card-exit' : 'animate-update-card-enter' - - const cardContent = (() => { - // ── Compact transient states (user-initiated check feedback) ────── - - if (status.state === 'checking') { - return ( - - ) - } - - if (status.state === 'not-available') { - return ( - - ) - } - - // ── Error states ───────────────────────────────────────────────── - - // Why: the package-recovery card owns its own async validation state, so it branches before the generic models. - if (linuxPackageRecovery) { - return ( - - ) - } - - if (errorCard) { - return - } - - // ── Downloaded state ───────────────────────────────────────────── - - if (status.state === 'downloaded') { - if (hasStartedDownload.current) { - return ( -
-

- {translate('auto.components.UpdateCard.09a55c39b5', 'Installing...')} -

-
- ) - } - // Settings-initiated download — show "Ready to install" - return ( - - ) - } - - // ── Downloading state ──────────────────────────────────────────── - - if (status.state === 'downloading') { - return ( - setMediaFailed(true)} - onMediaLoad={() => setMediaLoaded(true)} - onCollapse={handleCollapseWithAnimation} - showReleaseNotes={!isLocalBuild} - /> - ) - } - - // ── Available state ────────────────────────────────────────────── - - if (status.state !== 'available') { - return null - } - - const releaseUrl = isLocalBuild - ? undefined - : (('releaseUrl' in status ? status.releaseUrl : undefined) ?? - getReleaseNotesUrlForVersion(status.version)) - - if (isRichMode && changelog) { - return ( - setMediaFailed(true)} - onMediaLoad={() => setMediaLoaded(true)} - onUpdate={handleUpdate} - onClose={handleDismissWithAnimation} - /> - ) - } - - return ( - - ) - })() - - // One-time reassurance tip that updating won't kill running terminals; persisted once seen. const showReassurance = !reassuranceSeen && (status.state === 'available' || status.state === 'downloading') - return (
{showReassurance && ( @@ -638,7 +281,7 @@ export function UpdateCard() { )} ) } - -// ── Rich card content ──────────────────────────────────────────────── - -function RichCardContent({ - release, - releasesBehind, - prefersReducedMotion, - mediaFailed, - mediaLoaded, - onMediaError, - onMediaLoad, - onUpdate, - onClose -}: { - release: NonNullable - releasesBehind: number | null - prefersReducedMotion: boolean - mediaFailed: boolean - mediaLoaded: boolean - onMediaError: () => void - onMediaLoad: () => void - onUpdate: () => void - onClose: () => void -}) { - const showMedia = - release.mediaUrl && - !mediaFailed && - // Why: GIFs can't be reliably paused cross-browser, so hide them entirely under reduced-motion. - !(prefersReducedMotion && isAnimatedGif(release.mediaUrl)) - - return ( -
-
-

- {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title} -

- -
- - {showMedia && ( -
- {!mediaLoaded && ( - // Shimmer placeholder while image loads -
- )} - -
- )} - -

- {release.description} - {releasesBehind !== null && releasesBehind > 1 && ( - <> - {' '} - - - )} -

- - - - -
- ) -} - -// ── Simple card content ────────────────────────────────────────────── - -function SimpleCardContent({ - version, - releaseUrl, - onUpdate, - onClose -}: { - version: string - releaseUrl?: string - onUpdate: () => void - onClose: () => void -}) { - return ( -
-
-

- {translate('auto.components.UpdateCard.9abc59f814', 'Update Available')} -

- -
- -

- {translate('auto.components.UpdateCard.05ad78a6d1', 'Orca v{{value0}} is ready.', { - value0: version - })} -

- -

- {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")} -

- - {releaseUrl && ( - - )} - - -
- ) -} - -// ── Downloading content ────────────────────────────────────────────── - -function DownloadingContent({ - version, - percent, - changelog, - prefersReducedMotion, - mediaFailed, - mediaLoaded, - onMediaError, - onMediaLoad, - onCollapse, - showReleaseNotes -}: { - version: string - percent: number - changelog: ChangelogData | null - prefersReducedMotion: boolean - mediaFailed: boolean - mediaLoaded: boolean - onMediaError: () => void - onMediaLoad: () => void - onCollapse: () => void - showReleaseNotes: boolean -}) { - const release = changelog?.release - const showMedia = - release?.mediaUrl && !mediaFailed && !(prefersReducedMotion && isAnimatedGif(release.mediaUrl)) - - return ( -
-
- {release ? ( -

- {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title} -

- ) : ( -

- {translate('auto.components.UpdateCard.558842597d', 'Downloading Update')} -

- )} - -
- - {showMedia && release?.mediaUrl && ( -
- {!mediaLoaded && ( -
- )} - -
- )} - -

- {release - ? release.description - : translate('auto.components.UpdateCard.93794ea932', 'Orca v{{value0}} is downloading.', { - value0: version - })} -

- - {showReleaseNotes && ( - - )} - -
- -

- {translate('auto.components.UpdateCard.6e45bfa2e0', 'Downloading...')} {percent}% -

-
-
- ) -} - -// ── Ready to install content ───────────────────────────────────────── - -function ReadyToInstallContent({ - version, - onRestart, - onClose -}: { - version: string - onRestart: () => void - onClose: () => void -}) { - return ( -
-
-

- {translate('auto.components.UpdateCard.17412483da', 'Ready to Install')} -

- -
- -

- {translate( - 'auto.components.UpdateCard.6714206e5a', - "Orca v{{value0}} is downloaded. Restart when you're ready.", - { value0: version } - )} -

- - -
- ) -} diff --git a/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx b/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx new file mode 100644 index 00000000000..f8efab939af --- /dev/null +++ b/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx @@ -0,0 +1,160 @@ +import type { JSX, ReactNode } from 'react' +import { ClaudeIcon } from '@/components/status-bar/icons' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { + BROWSER_PROMPT_TEXT, + browserPhaseAtLeast, + type BrowserVisualPhase +} from './browser-animated-visual-phase' + +type TerminalEntry = + | { kind: 'prompt'; text: string } + | { kind: 'working' } + | { kind: 'ok'; content: ReactNode } + | { kind: 'tool'; tool: string; arg: string } + | { kind: 'tool-muted'; tool: string; muted: string } + +type PhasedTerminalEntry = { + id: string + entry: TerminalEntry + minPhase: BrowserVisualPhase +} + +function terminalEntries(): readonly PhasedTerminalEntry[] { + return [ + { + id: 'change-prompt', + entry: { kind: 'prompt', text: BROWSER_PROMPT_TEXT }, + minPhase: 'working' + }, + { id: 'working', entry: { kind: 'working' }, minPhase: 'working' }, + { + id: 'updated', + entry: { + kind: 'ok', + content: ( + <> + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', + '✓ Updated' + )}{' '} + + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', + '.pp-card[data-card="starter"] .pp-cta' + )} + + + ) + }, + minPhase: 'updated' + }, + { + id: 'verify-prompt', + entry: { + kind: 'prompt', + text: 'Let me click Try free to verify it still works.' + }, + minPhase: 'verify-intent' + }, + { + id: 'click', + entry: { kind: 'tool', tool: 'click', arg: '"Try free"' }, + minPhase: 'click-press' + }, + { + id: 'screenshot', + entry: { kind: 'tool-muted', tool: 'screenshot', muted: '(capturing page)' }, + minPhase: 'screenshot-line' + }, + { + id: 'verified', + entry: { + kind: 'ok', + content: translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', + '✓ Verified — Try free still works.' + ) + }, + minPhase: 'verified' + } + ] +} + +export function BrowserAgentVerificationPane(props: { + phase: BrowserVisualPhase + splitVisible: boolean +}): JSX.Element { + return ( +
+
+ + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.6e4616d039', 'Claude')} + +
+
+ {terminalEntries().map(({ id, entry, minPhase }) => ( + + + + ))} +
+
+ ) +} + +function TerminalEntryView(props: { entry: TerminalEntry }): JSX.Element { + const { entry } = props + if (entry.kind === 'prompt') { + return ( + + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.f2034c4930', '>')} + {' '} + {entry.text} + + ) + } + if (entry.kind === 'working') { + return ( + + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.0ce7c24b4d', 'Working…')} + + ) + } + if (entry.kind === 'ok') { + return {entry.content} + } + if (entry.kind === 'tool') { + return ( + + {entry.tool}{' '} + {entry.arg} + + ) + } + return ( + + {entry.tool}{' '} + {entry.muted} + + ) +} + +function TerminalLine(props: { visible: boolean; children: ReactNode }): JSX.Element { + return ( + + {props.children} + + ) +} diff --git a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx index 3d229ddaa91..2966f05a60b 100644 --- a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx @@ -1,499 +1,49 @@ -/* eslint-disable max-lines -- Why: this animation is a self-contained storyboard; splitting the phase markup from its timing constants would make the sequence harder to verify. */ -import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react' -import { cn } from '@/lib/utils' -import { ClaudeIcon } from '@/components/status-bar/icons' +import type { JSX } from 'react' import { useShortcutLabel } from '@/hooks/useShortcutLabel' -import { FeatureWallClickRing } from './FeatureWallClickRing' import { translate } from '@/i18n/i18n' - -// Why: this animation tells the full Orca story end-to-end — the user opens a -// new browser tab, annotates a target on the pricing page, types a change, -// hands off to Claude in a split pane, and Claude edits + verifies the page. -// The DOM and timing track docs/feature-wall-workbench-tile-mock.html so the -// modal stays in lockstep with the design source. - -const PROMPT_TEXT = 'Make Starter card stand out' - -// Why: these hand-rolled tour popovers need the same dark-mode separation as -// Orca's dropdown/popover primitives while staying inside the storyboard DOM. -const TOUR_FLOATING_SURFACE_CLASS = - 'border border-black/14 bg-[rgba(255,255,255,0.82)] text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)]' - -const PRE_INTRO_MS = 600 -const NEWTAB_APPROACH_MS = 700 -const NEWTAB_CLICK_MS = 180 -const NEWTAB_DWELL_MS = 700 -const NEWTAB_ROW_HOVER_MS = 1050 -const NEWTAB_ROW_CLICK_MS = 220 -const TAB_REVEAL_MS = 500 -const APPROACH_CARD_MS = 900 -const INSPECT_MS = 700 -const ANNOTATE_OPEN_MS = 360 -const ANNOTATE_TYPE_INTERVAL_MS = 58 -const ANNOTATE_HOLD_MS = 900 -const SEND_APPROACH_MS = 500 -const SEND_CLICK_MS = 250 -const HANDOFF_MS = 200 -const WORKING_LINE_STAGGER_MS = 260 -const WORKING_HOLD_MS = 1400 -const UPDATED_HOLD_MS = 900 -const VERIFY_INTENT_MS = 1100 -const CLICK_APPROACH_MS = 620 -const CLICK_PRESS_MS = 280 -const NAVIGATED_HOLD_MS = 700 -const SCREENSHOT_LINE_HOLD_MS = 420 -const SCREENSHOT_FLASH_HOLD_MS = 700 -const VERIFIED_HOLD_MS = 2400 -const RESET_HOLD_MS = 300 -const CLICK_RING_MS = 460 - -type Phase = - | 'idle' - | 'newtab-approach' - | 'newtab-click' - | 'newtab-row-approach' - | 'newtab-row-click' - | 'tab-revealed' - | 'approach-card' - | 'inspect' - | 'annotate' - | 'send-approach' - | 'send-click' - | 'handoff' - | 'working' - | 'updated' - | 'verify-intent' - | 'click-approach' - | 'click-press' - | 'navigated' - | 'screenshot-line' - | 'screenshot-flash' - | 'verified' - -const PHASE_ORDER: readonly Phase[] = [ - 'idle', - 'newtab-approach', - 'newtab-click', - 'newtab-row-approach', - 'newtab-row-click', - 'tab-revealed', - 'approach-card', - 'inspect', - 'annotate', - 'send-approach', - 'send-click', - 'handoff', - 'working', - 'updated', - 'verify-intent', - 'click-approach', - 'click-press', - 'navigated', - 'screenshot-line', - 'screenshot-flash', - 'verified' -] - -function phaseAtLeast(current: Phase, target: Phase): boolean { - return PHASE_ORDER.indexOf(current) >= PHASE_ORDER.indexOf(target) -} - -const SPLIT_PHASES: readonly Phase[] = [ - 'working', - 'updated', - 'verify-intent', - 'click-approach', - 'click-press', - 'navigated', - 'screenshot-line', - 'screenshot-flash', - 'verified' -] - -function isSplitPhase(phase: Phase): boolean { - return SPLIT_PHASES.includes(phase) -} - -type TermEntry = - | { kind: 'prompt'; text: string } - | { kind: 'working' } - | { kind: 'ok'; html: ReactNode } - | { kind: 'tool'; tool: string; arg: string } - | { kind: 'tool-muted'; tool: string; muted: string } - -const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [ - { entry: { kind: 'prompt', text: PROMPT_TEXT }, minPhase: 'working' }, - { entry: { kind: 'working' }, minPhase: 'working' }, - { - entry: { - kind: 'ok', - get html() { - return ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', - '✓ Updated' - )}{' '} - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', - '.pp-card[data-card="starter"] .pp-cta' - )} - - - ) - } - }, - minPhase: 'updated' - }, - { - entry: { - kind: 'prompt', - text: 'Let me click Try free to verify it still works.' - }, - minPhase: 'verify-intent' - }, - { entry: { kind: 'tool', tool: 'click', arg: '"Try free"' }, minPhase: 'click-press' }, - { - entry: { kind: 'tool-muted', tool: 'screenshot', muted: '(capturing page)' }, - minPhase: 'screenshot-line' - }, - { - entry: { - kind: 'ok', - get html() { - return ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', - '✓ Verified — Try free still works.' - )} - - ) - } - }, - minPhase: 'verified' - } -] +import { BrowserAgentVerificationPane } from './BrowserAgentVerificationPane' +import { BrowserStoryboardWindow } from './BrowserStoryboardWindow' +import { + BROWSER_REDUCED_MOTION_STATE, + isBrowserSplitPhase, + type BrowserVisualState, + type BrowserVisualTargetRefs +} from './browser-animated-visual-phase' +import { + useBrowserAnimatedVisualStoryboard, + useBrowserVisualTargetRefs +} from './use-browser-animated-visual-storyboard' export function BrowserAnimatedVisual(props: { reducedMotion: boolean onCycleComplete?: () => void }): JSX.Element { - const { reducedMotion, onCycleComplete } = props + const targets = useBrowserVisualTargetRefs() const newBrowserShortcutLabel = useShortcutLabel('tab.newBrowser') - const [phase, setPhase] = useState('idle') - const [typedChars, setTypedChars] = useState(0) - const [flashKey, setFlashKey] = useState(0) - const [clickRingKey, setClickRingKey] = useState(0) - const [clickRingVisible, setClickRingVisible] = useState(false) - const [menuOffsetX, setMenuOffsetX] = useState(0) - const [annotateAnchor, setAnnotateAnchor] = useState<{ left: number; top: number }>({ - left: 116, - top: 70 - }) + // Why: branch on the state source, not the element type, so a reducedMotion + // toggle re-renders the storyboard in place instead of remounting it. + const animatedState = useBrowserAnimatedVisualStoryboard( + targets, + props.reducedMotion, + props.onCycleComplete + ) - const browserPageRef = useRef(null) - const titlebarRef = useRef(null) - const newtabBtnRef = useRef(null) - const newtabRowRef = useRef(null) - const starterCardRef = useRef(null) - const ctaRef = useRef(null) - const sendBtnRef = useRef(null) - const cursorPosRef = useRef<{ x: number; y: number }>({ x: 40, y: 18 }) - const [cursorPos, setCursorPos] = useState<{ x: number; y: number }>({ x: 40, y: 18 }) - - // Why: cursor coordinates are computed against .browser-page so they survive - // grid reflow when the split lands. We stash both a ref (synchronous read - // for hover-then-click sequences) and state (for transition animation). - function setCursorTo(x: number, y: number): void { - cursorPosRef.current = { x, y } - setCursorPos({ x, y }) - } - - function transformForElement( - el: HTMLElement | null, - offsetX = 0, - offsetY = 0 - ): { x: number; y: number } { - const page = browserPageRef.current - if (!page || !el) { - return cursorPosRef.current - } - const pageRect = page.getBoundingClientRect() - const elRect = el.getBoundingClientRect() - return { - x: elRect.left - pageRect.left + elRect.width / 2 - 8 + offsetX, - y: elRect.top - pageRect.top + elRect.height / 2 - 8 + offsetY - } - } - - /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: this - storyboard effect owns timed local animation state; deriving every phase - during render would break measured cursor sequencing. */ - useEffect(() => { - if (reducedMotion) { - setPhase('verified') - setTypedChars(PROMPT_TEXT.length) - setClickRingVisible(false) - return - } - let cancelled = false - const timeouts: number[] = [] - const wait = (ms: number): Promise => - new Promise((resolve) => { - const id = window.setTimeout(() => resolve(), ms) - timeouts.push(id) - }) - function pulseClickRing(): void { - setClickRingKey((key) => key + 1) - setClickRingVisible(true) - const id = window.setTimeout(() => { - if (!cancelled) { - setClickRingVisible(false) - } - }, CLICK_RING_MS) - timeouts.push(id) - } - - async function loop(): Promise { - while (!cancelled) { - setPhase('idle') - setTypedChars(0) - setClickRingVisible(false) - setCursorTo(40, 18) - await wait(PRE_INTRO_MS) - if (cancelled) { - return - } - - // 1. Cursor approaches the "+" in the tab strip. - const newtabPos = transformForElement(newtabBtnRef.current) - setCursorTo(newtabPos.x, newtabPos.y) - setPhase('newtab-approach') - await wait(NEWTAB_APPROACH_MS) - if (cancelled) { - return - } - - // 2. Click "+". Capture the dropdown's left offset relative to the - // titlebar so the menu lines up with the button. - setPhase('newtab-click') - pulseClickRing() - if (titlebarRef.current && newtabBtnRef.current) { - const tbRect = titlebarRef.current.getBoundingClientRect() - const btnRect = newtabBtnRef.current.getBoundingClientRect() - setMenuOffsetX(btnRect.left - tbRect.left) - } - await wait(NEWTAB_CLICK_MS) - if (cancelled) { - return - } - await wait(NEWTAB_DWELL_MS) - if (cancelled) { - return - } - - // 3. Move to "New Browser Tab" row. - const rowPos = transformForElement(newtabRowRef.current, 6, 0) - setCursorTo(rowPos.x, rowPos.y) - setPhase('newtab-row-approach') - await wait(NEWTAB_ROW_HOVER_MS) - if (cancelled) { - return - } - - // 4. Click → dropdown closes, browser tab reveals, page comes alive. - setPhase('newtab-row-click') - pulseClickRing() - await wait(NEWTAB_ROW_CLICK_MS) - if (cancelled) { - return - } - setPhase('tab-revealed') - await wait(TAB_REVEAL_MS) - if (cancelled) { - return - } - - // Cursor approaches the Starter card. - const starterPos = transformForElement(starterCardRef.current, 0, -8) - setCursorTo(starterPos.x, starterPos.y) - setPhase('approach-card') - await wait(APPROACH_CARD_MS) - if (cancelled) { - return - } - - setPhase('inspect') - pulseClickRing() - await wait(INSPECT_MS) - if (cancelled) { - return - } - - // Anchor the annotate popover to the Starter card's actual position so - // it lines up regardless of how the parent grid lays out. - if (browserPageRef.current && starterCardRef.current) { - const pageRect = browserPageRef.current.getBoundingClientRect() - const cardRect = starterCardRef.current.getBoundingClientRect() - setAnnotateAnchor({ - left: cardRect.right - pageRect.left + 6, - top: cardRect.top - pageRect.top - }) - } - setPhase('annotate') - await wait(ANNOTATE_OPEN_MS) - if (cancelled) { - return - } - for (let i = 1; i <= PROMPT_TEXT.length; i += 1) { - if (cancelled) { - return - } - setTypedChars(i) - await wait(ANNOTATE_TYPE_INTERVAL_MS) - } - await wait(ANNOTATE_HOLD_MS) - if (cancelled) { - return - } - - const sendPos = transformForElement(sendBtnRef.current) - setCursorTo(sendPos.x, sendPos.y) - setPhase('send-approach') - await wait(SEND_APPROACH_MS) - if (cancelled) { - return - } - - setPhase('send-click') - pulseClickRing() - await wait(SEND_CLICK_MS) - if (cancelled) { - return - } - - setPhase('handoff') - await wait(HANDOFF_MS) - if (cancelled) { - return - } - - // Split lands. Working line + prompt are gated on phase >= 'working'; - // small stagger keeps them from popping in simultaneously. - setPhase('working') - await wait(WORKING_LINE_STAGGER_MS * 2) - if (cancelled) { - return - } - await wait(WORKING_HOLD_MS) - if (cancelled) { - return - } - - setPhase('updated') - await wait(UPDATED_HOLD_MS) - if (cancelled) { - return - } - - setPhase('verify-intent') - await wait(VERIFY_INTENT_MS) - if (cancelled) { - return - } - - const ctaPos = transformForElement(ctaRef.current) - setCursorTo(ctaPos.x, ctaPos.y) - setPhase('click-approach') - await wait(CLICK_APPROACH_MS) - if (cancelled) { - return - } - - setPhase('click-press') - pulseClickRing() - await wait(CLICK_PRESS_MS) - if (cancelled) { - return - } - - setPhase('navigated') - await wait(NAVIGATED_HOLD_MS) - if (cancelled) { - return - } - - setPhase('screenshot-line') - await wait(SCREENSHOT_LINE_HOLD_MS) - if (cancelled) { - return - } - - setPhase('screenshot-flash') - setFlashKey((k) => k + 1) - await wait(SCREENSHOT_FLASH_HOLD_MS) - if (cancelled) { - return - } - - setPhase('verified') - await wait(VERIFIED_HOLD_MS) - if (cancelled) { - return - } - onCycleComplete?.() - - await wait(RESET_HOLD_MS) - } - } - - loop() - return () => { - cancelled = true - timeouts.forEach((id) => window.clearTimeout(id)) - } - }, [onCycleComplete, reducedMotion]) - /* oxlint-enable react-doctor/no-adjust-state-on-prop-change */ - - const isIntroPhase = - phase === 'idle' || - phase === 'newtab-approach' || - phase === 'newtab-click' || - phase === 'newtab-row-approach' || - phase === 'newtab-row-click' - const browserChromeVisible = !isIntroPhase - const browserTabVisible = !isIntroPhase - const terminalTabMinimized = !isIntroPhase - const newtabActive = phase === 'newtab-click' || phase === 'newtab-row-approach' - const newtabRowActive = phase === 'newtab-row-approach' - const dropdownVisible = - phase === 'newtab-click' || phase === 'newtab-row-approach' || phase === 'newtab-row-click' - const cursorVisible = (phase !== 'idle' && phase !== 'navigated') || clickRingVisible - const ringStarter = - phase === 'inspect' || - phase === 'annotate' || - phase === 'send-approach' || - phase === 'send-click' || - phase === 'handoff' - const annotateOpen = phase === 'annotate' || phase === 'send-approach' || phase === 'send-click' - const sendPressed = phase === 'send-click' - const isSplit = isSplitPhase(phase) - const ctaHighlighted = phaseAtLeast(phase, 'updated') - const ctaPressing = phase === 'click-press' - const showSignup = - phase === 'navigated' || - phase === 'screenshot-line' || - phase === 'screenshot-flash' || - phase === 'verified' - const flashing = phase === 'screenshot-flash' - // While the cursor is acting on the titlebar / dropdown, let the cursor - // overflow the body's clipping bounds. Once the page is live we re-clip so - // the cursor never escapes the browser body. - const bodyOverflowVisible = isIntroPhase + return ( + + ) +} +function BrowserVisualFrame(props: { + state: BrowserVisualState + targets: BrowserVisualTargetRefs + newBrowserShortcutLabel: string +}): JSX.Element { + const isSplit = isBrowserSplitPhase(props.state.phase) return (
@@ -504,246 +54,12 @@ export function BrowserAnimatedVisual(props: { gap: isSplit ? 10 : 0 }} > - {/* Browser app-window — column 1 */} -
-
-
- } - title={translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', - 'Terminal 1' - )} - /> - {browserTabVisible ? ( - } - title={translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf', - 'localhost:3000' - )} - /> - ) : null} - - - -
- {/* New-tab dropdown menu */} -
- -
- - - - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.0a2bd01c02', - 'New Browser Tab' - )} - - - {newBrowserShortcutLabel} - -
- -
-
- - {/* URL toolbar — hidden until tab reveals so the panel reads as - "tab created → page came alive" instead of a static frame. */} -
- - ‹ - › - ↻ - -
- {isSplit ? ( - - {`...${showSignup ? '/signup' : '/pricing'}`} - - ) : ( - <> - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf', - 'localhost:3000' - )} - - - {showSignup - ? translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.f39be6ca14', - '/signup' - ) - : translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.73bbb46073', - '/pricing' - )} - - - )} -
-
- - {/* Browser body — relative + overflow-{hidden|visible} so the - cursor can escape during the new-tab intro to reach the - titlebar's "+" / dropdown rows. */} -
-
- {showSignup ? ( - - ) : ( - - )} - -
- - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.d8856b604a', - 'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta' - )} - - -
- {typedChars > 0 ? ( - <> - {PROMPT_TEXT.slice(0, typedChars)} - - - ) : ( - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.3d2352f94b', - 'Describe the change…' - )} - - )} -
-
- - - -
-
- - -
-
-
- - {clickRingVisible ? : null} -
-
-
-
- -
-
- - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.6e4616d039', - 'Claude' - )} - -
-
- {TERM_ENTRIES.map(({ entry, minPhase }, i) => ( - - - - ))} -
-
+ +