mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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, '')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) ?? ''
|
||||
}))
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user