Merge remote-tracking branch 'origin/main' into aug23-sta5269-pty-owner-identity

# Conflicts:
#	src/main/persistence/loading-store/store.ts
This commit is contained in:
Jinwoo-H
2026-08-25 00:32:05 -04:00
420 changed files with 42117 additions and 33817 deletions
-17
View File
@@ -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
+20 -2
View File
@@ -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.",
@@ -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'
@@ -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 = {
@@ -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'
@@ -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'
@@ -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'
@@ -28,7 +28,101 @@ afterEach(() => {
vi.restoreAllMocks()
})
async function postClaudeHook(
server: AgentHookServer,
payload: Record<string, unknown>
): Promise<Response> {
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' })
@@ -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'
+36 -24
View File
@@ -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,
@@ -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'
@@ -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 })
+37
View File
@@ -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()
)
})
})
+240
View File
@@ -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<string>()
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<string[]> {
return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName)
}
export async function searchBaseRefDetails(
path: string,
query: string,
limit = 25
): Promise<BaseRefSearchResult[]> {
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<string[]> {
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<string>()
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, '')
}
+59
View File
@@ -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<boolean> {
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<BranchConflictKind | null> {
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
}
+133
View File
@@ -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<boolean>
): Promise<string | null> {
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<string | null> {
return getDefaultBaseRefAsync(path, options)
}
export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
async function hasGitRefViaExec(exec: GitExec, ref: string): Promise<boolean> {
try {
await exec(['rev-parse', '--verify', '--quiet', ref])
return true
} catch {
return false
}
}
async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise<string | null> {
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<string | null> {
const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec)
if (originHeadBaseRef) {
return originHeadBaseRef
}
return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref))
}
export function resolveDefaultBaseRefWithLocalGit(
options: LocalDefaultBaseRefGitOptions
): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) =>
gitExecFileAsync(argv, {
...options,
timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS
})
)
}
export function getDefaultBaseRefAsync(
path: string,
options: LocalGitExecOptions = {}
): Promise<string | null> {
return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options))
}
+79
View File
@@ -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.<default>.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.'
)
})
})
+141
View File
@@ -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)
}
+251
View File
@@ -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<typeof statSync>
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())
}
+50 -915
View File
File diff suppressed because it is too large Load Diff
+189
View File
@@ -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<GitHubIssueTimelineItem[]> {
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 []
}
}
+260
View File
@@ -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<CollapsedIssueDetails | null> {
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: [] }
}
}
@@ -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)
)
}
@@ -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()
}
)
})
@@ -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<typeof makePR>) => void
let resolveSecond!: (outcome: ReturnType<typeof makePR>) => void
const first = new Promise<ReturnType<typeof makePR>>((resolve) => {
resolveFirst = resolve
})
const second = new Promise<ReturnType<typeof makePR>>((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({
File diff suppressed because it is too large Load Diff
@@ -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<GitHubPRRefreshEvent, 'sequence'>, 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
}
})
}
}
+114
View File
@@ -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<string, number[]>()
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]))
}
}
+242
View File
@@ -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<typeof setTimeout> | 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<void> {
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)
}
}
}
+225
View File
@@ -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<string, GitHubPRRefreshAlias>
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<string, PRRefreshQueueEntry>()
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<PRRefreshQueueEntry> {
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
})
}
}
@@ -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<number | null> {
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
}
+46
View File
@@ -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<string, { failures: number; retryAt: number }>()
private readonly manualRetryGates = new Map<string, number>()
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
}
}
}
+44
View File
@@ -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<number, { generation: number; keys: Set<string> }>()
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
}
}
@@ -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<GitHubPRFileContents> {
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()
}
}
+247
View File
@@ -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<string, GitHubPRFileViewedState>
}
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<GitHubPRFile[] | null> {
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<PRFileViewedStatesResult | null> {
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<string, GitHubPRFileViewedState>()
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'
}))
}
@@ -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<GitHubWorkItemDetails | null>
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<GitHubPRFileContents>
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<GetWorkItemDetails>()
expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf<GetPRFileContents>()
})
})
@@ -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,
File diff suppressed because it is too large Load Diff
+241
View File
@@ -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<string, GitHubAssignableUser>()
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<GitHubWorkItem, 'number' | 'type'>,
repository: GitHubApiRepository | null,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubAssignableUser[]> {
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<GitHubAssignableUser[]> {
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<GitHubAssignableUser[]> {
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<GitHubWorkItem, 'repoId'>,
knownUsers: GitHubAssignableUser[]
): Omit<GitHubWorkItem, 'repoId'> {
const avatarByLogin = new Map<string, string>()
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) ?? ''
}))
}
: {})
}
}
+31
View File
@@ -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)
)
}
@@ -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())
})
})
@@ -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)
)
})
}
+212
View File
@@ -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
}
)
}
+178
View File
@@ -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)
)
}
)
}
+161
View File
@@ -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<number>()
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
}
)
}
+180
View File
@@ -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
}
)
}
@@ -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)
)
}
+130
View File
@@ -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<GitHubPRRefreshEnqueueResult, { kind: 'skipped' }> } {
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)
}
}
+214
View File
@@ -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
})
}
)
}
@@ -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)
}
+16 -1307
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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')
}
@@ -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()
})
})
+18 -3
View File
@@ -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
}
@@ -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<PersistenceStore>().not.toHaveProperty('scheduleSave')
expectTypeOf<PersistenceStore>().not.toHaveProperty('enqueueWrite')
expectTypeOf<PersistenceStore>().not.toHaveProperty('getProjectHostOperations')
expectTypeOf<PersistenceStore>().not.toHaveProperty('getAutomationDefinitionOperations')
expectTypeOf<PersistenceStore>().not.toHaveProperty('getSshTargetStateOperations')
})
})
@@ -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<void>((resolve) => {
markRenameStarted = resolve
})
this.renameRelease = new Promise<void>((resolve) => {
releaseRename = resolve
})
},
markRenameStarted(): void {
markRenameStarted?.()
},
releaseRename(): void {
releaseRename?.()
}
}
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
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<typeof actual.rename>) => {
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')
})
})
+5 -9
View File
@@ -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<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
@@ -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<typeof NodeFsPromises>()
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
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<string, { scrollbackRefsByLeafId?: Record<string, string> }>
}
}
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<string, { scrollbackRefsByLeafId?: Record<string, string> }>
}
}
expect(
stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab']
?.scrollbackRefsByLeafId?.[TEST_LEAF_2]
).toBe(ref)
})
it('reads only the replay tail from oversized terminal scrollback snapshots', async () => {
@@ -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']
@@ -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<ProtectedSecretPersistence['removeRetainedBlob']>[0]
) => void
@@ -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<ProtectedSecretPersistence['removeRetainedBlob']>[0]
) => void
@@ -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
}
@@ -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<ProtectedSecretPersistence, 'isSealed' | 'removeRetainedBlob'>
flushDurableStateOrThrowAsync: () => Promise<void>
}
@@ -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
@@ -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<ProtectedSecretPersistence, 'removeRetainedBlob'>
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 {
@@ -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<ProtectedSecretPersistence, 'removeRetainedBlob'>
scheduleSave: () => void
flush: () => void
@@ -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<StoreRuntimeState, 'state'>
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]
})
}
@@ -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<boolean> {
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<StoreRuntimeState, 'backupRotationInFlight'>
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<boolean> {
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<void> {
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
}
}
@@ -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<StoreRuntimeState, 'loadNeedsSave'>
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()
}
}
}
}
}
@@ -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<StoreRuntimeState, 'loadNeedsSave' | 'state'>
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<string, number>()
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
}
}
@@ -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<string, unknown> = {}
): 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
}
}
@@ -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<StoreRuntimeState, 'state'>
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<string, WorktreeMeta> {
return this[metadataLineageOperationsContext].runtime.state.worktreeMeta
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): 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<ExecutionHostId>(
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<string, WorktreeLineage> {
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<WorkspaceKey, WorkspaceLineage> {
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]
})
}
@@ -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<StoreRuntimeState, 'state'>
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]
})
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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
}
@@ -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
}
}
@@ -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<GlobalSettings, 'defaultTaskSource' | 'visibleTaskProviders'>
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
}
}
@@ -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<GlobalSettings['sourceControlAi']>
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
}
}
@@ -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<void> {
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<void> {
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]
})
}
@@ -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<GlobalSettings>,
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<GlobalSettings>,
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<PersistedState['ui']>): 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<Omit<PersistedState['onboarding'], 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
}
): 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<GlobalSettings>,
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]
})
}
@@ -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<Repo, 'id' | 'connectionId' | 'executionHostId'>,
username: string
): boolean {
return getProjectHostOperations(this).setResolvedRepoGitUsername(target, username)
}
getProjectGroups(): ProjectGroup[] {
return getProjectGroupOperations(this).getProjectGroups()
}
createProjectGroup(
input: Parameters<ProjectGroupPersistenceOperations['createProjectGroup']>[0]
): ProjectGroup {
return getProjectGroupOperations(this).createProjectGroup(input)
}
updateProjectGroup(
groupId: string,
updates: Parameters<ProjectGroupPersistenceOperations['updateProjectGroup']>[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<FolderWorkspacePersistenceOperations['createFolderWorkspace']>[0]
): FolderWorkspace {
return getFolderWorkspaceOperations(this).createFolderWorkspace(input)
}
updateFolderWorkspace(
id: string,
updates: Parameters<FolderWorkspacePersistenceOperations['updateFolderWorkspace']>[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]
})
}
@@ -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<StoreRuntimeState, 'flushOrThrow' | 'state'>
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]
})
}
@@ -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]
})
}
@@ -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<string>()
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<string, RetiredNameRegistry> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const byRepo: Record<string, RetiredNameRegistry> = {}
for (const [repoId, row] of Object.entries(value as Record<string, unknown>)) {
if (!repoId) {
continue
}
const registry = normalizeRetiredNameRegistry(row)
if (!isEmptyRetiredNameRegistry(registry)) {
byRepo[repoId] = registry
}
}
return byRepo
}
@@ -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<StoreRuntimeState, 'state'>
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<string>): boolean {
if (!repoId) {
return false
}
const incoming = new Set<string>()
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<string>): boolean {
if (!namespaceKey) {
return false
}
const normalized = new Set<string>()
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<string>
): 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]
})
}
@@ -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<ExecutionHostId>([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]
})
}
@@ -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]
})
}
@@ -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<StoreRuntimeState, 'state'>
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]
})
}
@@ -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<StoreRuntimeState, 'protectedSecrets' | 'state'>
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<void> {
await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record)
}
async removeSshPtyConsumerRecovery(targetId: string): Promise<void> {
await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId)
}
getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] {
return getSshRemotePtyLeasesOperation(
this[sshLeaseRecoveryOperationsContext].runtime.state,
targetId
)
}
upsertSshRemotePtyLease(
lease: Omit<SshRemotePtyLease, 'createdAt' | 'updatedAt'> &
Partial<Pick<SshRemotePtyLease, 'createdAt' | 'updatedAt'>>
): 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<void> {
await markSshRemotePtyLeasesAsyncOperation(getSshPtyLeaseOperations(this), targetId, state)
}
async markSshRemotePtyLeasesAttachedAsync(
targetId: string,
ptyIds: readonly string[]
): Promise<void> {
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]
})
}
@@ -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<StoreRuntimeState, 'protectedSecrets' | 'state'>
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<Omit<SshTarget, 'id'>>): 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]
})
}
@@ -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<PersistedState, 'githubCache'> {
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 }
}
}
@@ -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
}
}
@@ -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
@@ -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<typeof setTimeout> | null = null
pendingWrite: Promise<void> | null = null
pendingSnapshotFileWork: Promise<void> | null = null
readonly staleTempCleanup: Promise<void>
writeGeneration = 0
inFlightAsyncTmpFile: string | null = null
backupRotationInFlight = false
writesFrozen = false
quitFlushStarted = false
quitFlushPromise: Promise<void> | null = null
lastWrittenStateHash: string | null = null
lastDurableWriteGeneration = -1
firstPendingSaveAt: number | null = null
githubCacheDirty = false
githubCacheGeneration = 0
pendingGithubCacheWrite: Promise<void> | null = null
readonly staleGithubCacheTempCleanup: Promise<void>
readonly gitUsernameCache = new Map<string, string>()
readonly protectedSecrets = new ProtectedSecretPersistence()
loadNeedsSave = false
flushOrThrow!: () => void
settingsChangeListeners = new Set<
(
updates: Partial<GlobalSettings>,
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
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<StoreRuntimeState, 'state'>
export class TerminalBindingRecoveryOperations {
constructor(private readonly runtime: TerminalBindingRecoveryOperationsRuntime) {}
getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set<string> {
const leafIds = new Set<string>()
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
}
}
@@ -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<keyof WorkspaceSessionState>([
'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)
}
}
}
@@ -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<Record<ExecutionHostId, WorkspaceSessionState>>; repaired: boolean } {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { partitions: {}, repaired: raw !== undefined }
}
let repaired = false
const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
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 }
}
@@ -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<ExecutionHostId, WorkspaceSessionPaneIdentityRemap>([
[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<string, string>()
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
}

Some files were not shown because too many files have changed in this diff Show More