refactor(persistence): extract modules to half persistence.ts (#14252)

* refactor(persistence): extract modules to half persistence.ts

* refactor(persistence): tighten the extracted operations seam

Review follow-ups on the module extraction, all behavior-neutral.

The extracted operations read and mutate the Store's state object in place, but
every seam typed it as a bare PersistedState, so nothing at the boundary said a
caller must pass the live reference — a future caller handing over a clone would
have its writes silently dropped. Name that contract: StoreOwnedPersistedState
carries it to every operations interface and every mutating free function.
normalizePersistedPaneIdentityState and backfillFolderScopeConnectionIds stay on
PersistedState; they build a fresh state rather than mutating the Store's.

The six *PersistenceOperations wrappers were constructed per delegate call. They
are stateless today, so this was inert, but any future instance state would be
lost between calls. Memoize them, and mark state and gitUsernameCache readonly
so the compiler enforces the single-assignment invariant memoizing them relies
on.

Also: restore flushSshPtyConsumerRecovery, whose inlining left its rationale
duplicated at both call sites; document that migrateWorktreeIdentity's boolean
gates the caller's save, since the extracted function kept no docs of its own;
and merge a duplicate shared/types import that was failing lint under
--deny-warnings.

* delete plan doc

* refactor(persistence): add error recovery and improve field cleanup

- Rollback failed migrations to prevent corrupted state that blocks retry
- Gracefully skip malformed entries in normalization instead of aborting
- Strip retired fields to prevent orphaned state and sync issues

* refactor(persistence): drop the redundant persistence- filename prefix

The extracted modules already live in src/main/persistence/, so name
them after the domain they own. Point leftover shared/types imports
at the real type modules while touching those files.

* refactor(persistence): optimize lookups and fix unsanitized updates

- Use Maps instead of repeated array searches for O(1) lookups
- Apply sanitized updates instead of raw input in ui-state-update
- Compare fields directly rather than JSON strings to avoid false dirty states from persisted key ordering differences

* refactor(persistence): group modules into lifecycle folders

Move the 42 flat persistence modules into six folders named for what the
module does, and lift the Store class out of the barrel so persistence.ts
becomes an 8-line public surface.

Bodies are unchanged: every moved file diffs clean against HEAD once import
blocks are excluded. Only import specifiers were rewritten, by resolving each
one to an absolute path and mapping it through the move map.

Store keeps its existing max-lines suppression; its baseline entry is repathed
rather than re-added. Its 119-method public API sets a ~525-line floor, so it
cannot meet the 400-line cap without breaking the API for 153 importers.

* Sanitize worktree visibility sources and preferences on hydration

Ensure invalid or corrupted data from disk (untracked whitespace,
relative paths, bogus preference values) is cleaned during load
rather than corrupting the in-memory store.
This commit is contained in:
Jinjing
2026-08-15 21:41:01 -07:00
committed by GitHub
parent 1f483c3e4e
commit 8b04e060fa
47 changed files with 9550 additions and 8017 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence.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
@@ -3,6 +3,7 @@ import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { GlobalSettings } from '../shared/global-settings-types'
import type { ExternalWorktreeVisibility } from '../shared/repo-types'
import { getDefaultPersistedState } from '../shared/constants'
import { testState, createStore, writeDataFile, makeRepo } from './persistence-test-harness'
@@ -234,6 +235,33 @@ describe('Store', () => {
expect(reloaded.getRepo('r1')?.externalWorktreeVisibilityLegacy).toBe(false)
})
it('sanitizes raw custom worktree visibility sources on the read path', async () => {
const persisted = getDefaultPersistedState(testState.dir)
persisted.repos = [
makeRepo({
id: 'r1',
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team-worktrees ' },
{ id: 'invalid', rootPath: '../relative' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' },
custom: { team: 'show', missing: 'bogus' as unknown as ExternalWorktreeVisibility }
}
})
]
writeDataFile(persisted)
const store = await createStore()
expect(store.getRepo('r1')).toMatchObject({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' },
custom: { team: 'show' }
}
})
})
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
const store = await createStore()
store.addRepo(
+8 -8016
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import {
compareFeatureInteractionUsageBuckets,
getFeatureInteractionCategory,
getFeatureInteractionUsageBucket,
normalizeFeatureInteractions,
normalizeFeatureInteractionTelemetryBuckets,
type FeatureInteractionId
} from '../../../shared/feature-interactions'
import { track } from '../../telemetry/client'
import { getCohortAtEmit } from '../../telemetry/cohort-classifier'
export type FeatureInteractionOperations = {
state: StoreOwnedPersistedState
scheduleSave: () => void
notifyUIChanged: () => void
getUI: () => PersistedState['ui']
}
export function recordFeatureInteraction(
operations: FeatureInteractionOperations,
id: FeatureInteractionId
): PersistedState['ui'] {
const featureInteractions = normalizeFeatureInteractions(operations.state.ui?.featureInteractions)
const telemetryBuckets = normalizeFeatureInteractionTelemetryBuckets(
operations.state.featureInteractionTelemetryBuckets
)
const existing = featureInteractions[id]
const previousCount = existing?.interactionCount ?? 0
const nextCount = previousCount + 1
const previousBucket = getFeatureInteractionUsageBucket(previousCount)
const nextBucket = getFeatureInteractionUsageBucket(nextCount)
const lastEmittedBucket = telemetryBuckets[id] ?? null
const shouldEmit =
nextBucket !== null &&
(lastEmittedBucket === null ||
compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0)
operations.state.ui = {
...operations.state.ui,
featureInteractions: {
...featureInteractions,
[id]: {
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
interactionCount: nextCount
}
}
}
operations.state.featureInteractionTelemetryBuckets = shouldEmit
? { ...telemetryBuckets, [id]: nextBucket }
: telemetryBuckets
operations.scheduleSave()
// Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer.
if (!existing) {
operations.notifyUIChanged()
}
if (shouldEmit) {
track('feature_interaction_usage_bucket_reached', {
feature_id: id,
feature_category: getFeatureInteractionCategory(id),
count_bucket: nextBucket,
bucket_source:
lastEmittedBucket === null && previousBucket !== null && previousBucket === nextBucket
? 'observed_existing'
: 'crossed_now',
...getCohortAtEmit()
})
}
return operations.getUI()
}
@@ -0,0 +1,214 @@
import type {
OnboardingChecklistState,
OnboardingOutcome,
OnboardingState
} from '../../../shared/onboarding-state-types'
import type { NotificationSettings } from '../../../shared/notification-settings-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import {
getDefaultNotificationSettings,
getDefaultOnboardingState,
ONBOARDING_FINAL_STEP,
ONBOARDING_FLOW_VERSION
} from '../../../shared/constants'
export function normalizeNotificationSettings(value: unknown): NotificationSettings {
const defaults = getDefaultNotificationSettings()
const candidate =
value && typeof value === 'object' ? (value as Partial<NotificationSettings>) : {}
const rawSoundId = (candidate as { customSoundId?: unknown }).customSoundId
const customSoundId =
rawSoundId === 'system' ||
rawSoundId === 'two-tone' ||
rawSoundId === 'bong' ||
rawSoundId === 'thump' ||
rawSoundId === 'blip' ||
rawSoundId === 'sonar' ||
rawSoundId === 'blop' ||
rawSoundId === 'ding' ||
rawSoundId === 'clack' ||
rawSoundId === 'beep' ||
rawSoundId === 'custom'
? rawSoundId
: rawSoundId === 'orca' || rawSoundId === 'chime'
? 'two-tone'
: rawSoundId === 'pop'
? 'blop'
: typeof candidate.customSoundPath === 'string'
? 'custom'
: defaults.customSoundId
const rawVolume = candidate.customSoundVolume
const customSoundVolume =
typeof rawVolume === 'number' && Number.isFinite(rawVolume)
? Math.min(100, Math.max(0, rawVolume))
: defaults.customSoundVolume
return {
...defaults,
...candidate,
customSoundId,
customSoundVolume
}
}
export type SanitizeOnboardingUpdateOptions = {
migrateLegacyProgress?: boolean
}
export function remapLegacyOnboardingLastCompletedStep(
lastCompletedStep: number,
raw: Record<string, unknown>
): number {
if (raw.outcome === 'completed' && lastCompletedStep >= 4) {
return ONBOARDING_FINAL_STEP
}
// Why: v3 (pre-Windows-terminal-page) step 4 already meant notifications, so resume there, not the inserted Windows step.
if (raw.flowVersion === 3) {
return Math.min(4, lastCompletedStep)
}
// Why: v2's five-step flow had step 4 = removed agent setup, not completed integrations.
if (raw.flowVersion === 2) {
if (lastCompletedStep === 3) {
return 2
}
if (lastCompletedStep >= 4) {
return 3
}
return lastCompletedStep
}
if (lastCompletedStep === 3) {
return 2
}
if (lastCompletedStep === 4) {
return 2
}
if (lastCompletedStep >= 5) {
return 3
}
return lastCompletedStep
}
export function sanitizeOnboardingUpdate(
input: unknown,
options: SanitizeOnboardingUpdateOptions = {}
): Partial<Omit<OnboardingState, 'checklist'>> & { checklist?: Partial<OnboardingChecklistState> } {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return {}
}
const raw = input as Record<string, unknown>
const out: Partial<Omit<OnboardingState, 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
} = {}
if ('closedAt' in raw) {
// Why: NaN/Infinity serialize to null on save, reverting closedAt and reopening the wizard; require a finite timestamp.
if (typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) && raw.closedAt >= 0) {
out.closedAt = raw.closedAt
} else if (raw.closedAt === null) {
out.closedAt = null
}
// else: omit — preserve existing persisted value on merge.
}
if ('outcome' in raw) {
const v = raw.outcome
if (v === 'completed' || v === 'dismissed') {
out.outcome = v as OnboardingOutcome
} else if (v === null) {
out.outcome = null
}
// else: omit.
}
if ('flowVersion' in raw) {
const v = raw.flowVersion
if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= ONBOARDING_FLOW_VERSION) {
out.flowVersion = v
}
// else: omit.
}
if ('lastCompletedStep' in raw) {
const v = raw.lastCompletedStep
if (typeof v === 'number' && Number.isInteger(v) && v >= -1) {
const isLegacyFlow =
options.migrateLegacyProgress && raw.flowVersion !== ONBOARDING_FLOW_VERSION
// Why: removing two wizard pages changed step numbering; migrate legacy values before the final-step bound drops them.
const normalized = isLegacyFlow ? remapLegacyOnboardingLastCompletedStep(v, raw) : v
if (normalized <= ONBOARDING_FINAL_STEP) {
out.lastCompletedStep = normalized
}
}
// else: omit.
}
if ('checklist' in raw) {
const rawChecklist = raw.checklist
if (rawChecklist && typeof rawChecklist === 'object' && !Array.isArray(rawChecklist)) {
// Why: copy ONLY caller-sent boolean keys so partial updates don't reset other checklist items to false.
const defaults = getDefaultOnboardingState().checklist
const rc = rawChecklist as Record<string, unknown>
const checklist: Partial<OnboardingChecklistState> = {}
for (const key of Object.keys(defaults) as (keyof OnboardingChecklistState)[]) {
if (key in rc && typeof rc[key] === 'boolean') {
checklist[key] = rc[key] as boolean
}
}
out.checklist = checklist
}
}
if (options.migrateLegacyProgress) {
out.flowVersion = ONBOARDING_FLOW_VERSION
}
return out
}
export function normalizeLoadedOnboardingState(
input: unknown,
defaults: OnboardingState
): OnboardingState {
// Why: an existing file with no onboarding block is an upgrade user; backfill as completed so they skip the wizard.
if (!input) {
return {
...defaults,
closedAt: Date.now(),
outcome: 'completed',
lastCompletedStep: ONBOARDING_FINAL_STEP
}
}
// Why: sanitize persisted onboarding keys so a type-flipped field on disk can't poison in-memory state.
const sanitized = sanitizeOnboardingUpdate(input, {
migrateLegacyProgress: true
})
// Why: a completed/dismissed outcome means the user left; recover a bad closedAt instead of reopening the checklist.
const recoveredClosedAt =
typeof sanitized.closedAt === 'number'
? sanitized.closedAt
: sanitized.outcome !== null && sanitized.outcome !== undefined
? Date.now()
: sanitized.closedAt
return {
...defaults,
...sanitized,
closedAt: recoveredClosedAt ?? defaults.closedAt,
checklist: {
...defaults.checklist,
...sanitized.checklist
}
}
}
export function resolveSetupGuideSidebarDismissedOnLoad(
persistedDismissed: unknown,
onboarding: OnboardingState
): boolean {
// Why: once onboarding is closed, persisted false is just the old default, not a user opt-in to the sidebar checklist.
return onboarding.closedAt !== null || persistedDismissed === true
}
// Why: read a settings field removed from GlobalSettings but still on disk; one-shot for the inline-agents migration.
export function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean {
return (
(parsed?.settings as { experimentalAgentDashboard?: boolean } | undefined)
?.experimentalAgentDashboard === true
)
}
export function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | undefined {
return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick
}
@@ -0,0 +1,247 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
import {
normalizeTuiAgentArgsRecord,
normalizeTuiAgentEnvRecord
} from '../../../shared/tui-agent-launch-defaults'
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy'
import { normalizeTaskProviderSettings } from '../../../shared/task-providers'
import { normalizeOpenInApplications } from '../../../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings'
import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order'
import { normalizeAppIconId } from '../../../shared/app-icon'
import { 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 {
addMobilePairingCustomAddress,
normalizeMobilePairingCustomAddress,
normalizeMobilePairingCustomAddresses
} from '../../../shared/mobile-pairing-custom-address'
import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
normalizeSourceControlAiSettings,
projectSourceControlAiToLegacyCommitMessageAi
} from '../../../shared/source-control-ai'
import {
PROTECTED_SECRET_SLOT,
type ProtectedSecretPersistence
} from '../../protected-secret-persistence'
import { normalizeNotificationSettings } from './onboarding-normalization'
import { retireLegacyInstructionsForClearedTextActionRecipes } from './source-control-settings'
import {
buildWorkspaceDirHistoryForUpdate,
stripRetiredGlobalSettings
} from './terminal-settings-migrations'
export type SettingsMutationOperations = {
state: StoreOwnedPersistedState
removeRetainedBlob: (
slot: Parameters<ProtectedSecretPersistence['removeRetainedBlob']>[0]
) => void
scheduleSave: () => void
notifySettingsChanged: (updates: Partial<GlobalSettings>, originWebContentsId?: number) => void
}
export function updateSettings(
operations: SettingsMutationOperations,
updates: Partial<GlobalSettings>,
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): GlobalSettings {
const sanitizedUpdates = stripRetiredGlobalSettings(updates)
if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) {
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie)
}
if ('httpProxyUrl' in updates && !updates.httpProxyUrl) {
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl)
}
// Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on".
if ('minimizeToTrayOnClose' in updates) {
sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true
}
if ('showMenuBarIcon' in updates) {
sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true
}
// Why: the artifact publish capability must be an exact boolean on disk; no truthy value grants it.
if ('artifactSharingEnabled' in updates) {
sanitizedUpdates.artifactSharingEnabled = updates.artifactSharingEnabled === true
}
if ('disabledTuiAgents' in updates) {
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
}
if ('worktreeVisibilityDefaults' in updates) {
sanitizedUpdates.worktreeVisibilityDefaults = {
...operations.state.settings.worktreeVisibilityDefaults,
...(normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) ?? {
external: 'hide'
})
}
}
if ('agentDefaultArgs' in updates) {
sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
sanitizedUpdates.agentYoloDefaultsMigrated = true
}
if ('agentDefaultEnv' in updates) {
sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv)
sanitizedUpdates.agentYoloDefaultsMigrated = true
}
if ('terminalQuickCommands' in updates) {
sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands(
updates.terminalQuickCommands
)
}
if ('terminalCustomThemes' in updates) {
sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes(
updates.terminalCustomThemes
)
}
if ('terminalCursorStyle' in updates) {
Object.assign(
sanitizedUpdates,
normalizeTerminalCursorStyleDefault(
{ terminalCursorStyle: updates.terminalCursorStyle },
{ preserveExplicitValue: true }
)
)
}
if ('terminalScrollbackRows' in updates) {
sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
updates.terminalScrollbackRows
)
}
if (
'terminalTuiScrollSensitivity' in updates ||
'terminalTuiScrollSensitivityDefaultedToOne' in updates
) {
sanitizedUpdates.terminalTuiScrollSensitivityDefaultedToOne = true
}
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
'visibleTaskProviders' in updates
? updates.visibleTaskProviders
: operations.state.settings.visibleTaskProviders,
defaultTaskSource:
'defaultTaskSource' in updates
? updates.defaultTaskSource
: operations.state.settings.defaultTaskSource
})
sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource
sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders
if ('visibleTaskProviders' in updates) {
sanitizedUpdates.visibleTaskProvidersDefaultedForJira = true
}
}
if ('autoRenameBranchFromWork' in updates || 'autoRenameBranchFromWorkDefaultedOn' in updates) {
sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true
}
if ('openInApplications' in updates) {
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications)
}
if ('terminalShortcutPolicy' in updates) {
sanitizedUpdates.terminalShortcutPolicy = normalizeTerminalShortcutPolicy(
updates.terminalShortcutPolicy
)
}
if ('sourceControlGroupOrder' in updates) {
sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder(
updates.sourceControlGroupOrder
)
}
if ('appIcon' in updates) {
sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon)
}
if ('uiLanguage' in updates) {
sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage)
}
if ('prBotAuthorOverrides' in updates) {
// Why: every writer (desktop IPC, web RPC, migrations) hits this boundary, so the persisted list stays bounded and well-formed.
sanitizedUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides(
updates.prBotAuthorOverrides
)
}
if ('mobilePairingCustomAddress' in updates) {
sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress(
updates.mobilePairingCustomAddress
)
}
if ('mobilePairingCustomAddresses' in updates) {
sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses(
updates.mobilePairingCustomAddresses
)
}
if (
'mobilePairingCustomAddress' in sanitizedUpdates ||
'mobilePairingCustomAddresses' in sanitizedUpdates
) {
const mobilePairingCustomAddress =
'mobilePairingCustomAddress' in sanitizedUpdates
? sanitizedUpdates.mobilePairingCustomAddress
: operations.state.settings.mobilePairingCustomAddress
if (mobilePairingCustomAddress) {
sanitizedUpdates.mobilePairingCustomAddresses = addMobilePairingCustomAddress(
sanitizedUpdates.mobilePairingCustomAddresses ??
operations.state.settings.mobilePairingCustomAddresses ??
[],
mobilePairingCustomAddress
)
}
}
const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate(
operations.state.settings,
sanitizedUpdates
)
if (historyWithPreviousLayout) {
sanitizedUpdates.workspaceDirHistory = historyWithPreviousLayout
}
// Why deep-merge telemetry: a partial update (e.g. flipping only `optedIn`) must not clobber siblings like `installId`.
const mergedTelemetry =
sanitizedUpdates.telemetry !== undefined
? { ...operations.state.settings.telemetry, ...sanitizedUpdates.telemetry }
: operations.state.settings.telemetry
if ('sourceControlAi' in sanitizedUpdates) {
sanitizedUpdates.sourceControlAi = retireLegacyInstructionsForClearedTextActionRecipes(
sanitizedUpdates.sourceControlAi,
operations.state.settings
)
const normalizedSourceControlAi = normalizeSourceControlAiSettings(
sanitizedUpdates.sourceControlAi,
operations.state.settings.commitMessageAi
)
sanitizedUpdates.sourceControlAi = normalizedSourceControlAi
sanitizedUpdates.commitMessageAi = projectSourceControlAiToLegacyCommitMessageAi(
normalizedSourceControlAi,
operations.state.settings.commitMessageAi
)
} else if ('commitMessageAi' in sanitizedUpdates) {
sanitizedUpdates.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi(
operations.state.settings.sourceControlAi,
sanitizedUpdates.commitMessageAi
)
}
const previousSettings = operations.state.settings
operations.state.settings = {
...operations.state.settings,
...sanitizedUpdates,
notifications: normalizeNotificationSettings({
...operations.state.settings.notifications,
...sanitizedUpdates.notifications
}),
...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {})
}
operations.scheduleSave()
const changedUpdates = {} as Partial<GlobalSettings> & Record<string, unknown>
for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) {
if (!Object.is(previousSettings[key], operations.state.settings[key])) {
changedUpdates[String(key)] = operations.state.settings[key]
}
}
if (options.notifyListeners === true && Object.keys(changedUpdates).length > 0) {
operations.notifySettingsChanged(changedUpdates, options.originWebContentsId)
}
return operations.state.settings
}
@@ -0,0 +1,46 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { normalizeSourceControlAiSettings } from '../../../shared/source-control-ai'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
SOURCE_CONTROL_TEXT_ACTION_IDS
} from '../../../shared/source-control-ai-actions'
export function retireLegacyInstructionsForClearedTextActionRecipes(
sourceControlAi: GlobalSettings['sourceControlAi'],
previousSettings: GlobalSettings
): GlobalSettings['sourceControlAi'] {
if (!sourceControlAi?.actions) {
return sourceControlAi
}
const previousSourceControlAi = normalizeSourceControlAiSettings(
previousSettings.sourceControlAi,
previousSettings.commitMessageAi
)
let instructionsByOperation = sourceControlAi.instructionsByOperation
let changed = false
for (const actionId of SOURCE_CONTROL_TEXT_ACTION_IDS) {
if (
sourceControlAi.actions[actionId]?.commandInputTemplate !==
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
) {
continue
}
if (
previousSourceControlAi.actions?.[actionId]?.commandInputTemplate ===
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] ||
instructionsByOperation?.[actionId] !==
previousSourceControlAi.instructionsByOperation[actionId]
) {
continue
}
if (instructionsByOperation?.[actionId] === '') {
continue
}
// Why: {basePrompt} is the explicit clear state; an empty instruction shadows rollback commitMessageAi.customPrompt on normalize/project.
instructionsByOperation = { ...instructionsByOperation, [actionId]: '' }
changed = true
}
return changed ? { ...sourceControlAi, instructionsByOperation } : sourceControlAi
}
@@ -0,0 +1,165 @@
import type { GlobalSettings, OrcaWorkspaceLayout } from '../../../shared/global-settings-types'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import {
legacyTerminalScrollbackBytesToRows,
normalizeDesktopTerminalScrollbackRows
} from '../../../shared/terminal-scrollback-policy'
import {
DEFAULT_TUI_AGENT_ARGS,
DEFAULT_TUI_AGENT_ENV,
normalizeTuiAgentArgsRecord,
normalizeTuiAgentEnvRecord
} from '../../../shared/tui-agent-launch-defaults'
export function buildWorkspaceDirHistoryForUpdate(
current: GlobalSettings,
updates: Partial<GlobalSettings>
): OrcaWorkspaceLayout[] | null {
if (!('workspaceDir' in updates) && !('nestWorkspaces' in updates)) {
return null
}
const nextPath = updates.workspaceDir ?? current.workspaceDir
const nextNestWorkspaces = updates.nestWorkspaces ?? current.nestWorkspaces
if (
normalizeRuntimePathForComparison(nextPath) ===
normalizeRuntimePathForComparison(current.workspaceDir) &&
nextNestWorkspaces === current.nestWorkspaces
) {
return null
}
const previousLayout = {
path: current.workspaceDir,
nestWorkspaces: current.nestWorkspaces
}
const existing = current.workspaceDirHistory ?? []
const next = [...existing]
const previousKey = getWorkspaceLayoutHistoryKey(previousLayout)
if (!next.some((layout) => getWorkspaceLayoutHistoryKey(layout) === previousKey)) {
next.push(previousLayout)
}
return next
}
export type LegacyTerminalScrollbackSettings = {
terminalScrollbackRows?: unknown
terminalScrollbackBytes?: unknown
}
export const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3
export function readLegacyTerminalScrollbackSettings(
settings: unknown
): LegacyTerminalScrollbackSettings {
return settings && typeof settings === 'object'
? (settings as LegacyTerminalScrollbackSettings)
: {}
}
type RetiredGlobalSettings = {
terminalScrollbackBytes?: unknown
enableGitHubAttribution?: unknown
}
export function stripRetiredGlobalSettings(
settings: Partial<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const {
terminalScrollbackBytes: _legacyScrollbackBytes,
enableGitHubAttribution: _legacyGitHubAttribution,
...rest
} = (settings ?? {}) as Partial<GlobalSettings> & RetiredGlobalSettings
void _legacyScrollbackBytes
void _legacyGitHubAttribution
return rest
}
export function migrateTerminalScrollbackRows(settings: unknown): {
rows: number
needsSave: boolean
} {
const legacySettings = readLegacyTerminalScrollbackSettings(settings)
const hasRows = Object.hasOwn(legacySettings, 'terminalScrollbackRows')
const hasLegacyBytes = Object.hasOwn(legacySettings, 'terminalScrollbackBytes')
const rows = hasRows
? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows)
: legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes)
return {
rows,
needsSave: !hasRows || hasLegacyBytes || legacySettings.terminalScrollbackRows !== rows
}
}
export function migrateTerminalTuiScrollSensitivityDefault(settings: GlobalSettings | undefined): {
settings: Pick<
GlobalSettings,
'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne'
>
needsSave: boolean
} {
const alreadyDefaultedToOne = settings?.terminalTuiScrollSensitivityDefaultedToOne === true
const current = settings?.terminalTuiScrollSensitivity
const shouldMoveInheritedDefault =
!alreadyDefaultedToOne &&
(current === undefined || current === LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT)
const terminalTuiScrollSensitivity = shouldMoveInheritedDefault ? 1 : (current ?? 1)
return {
settings: {
terminalTuiScrollSensitivity,
terminalTuiScrollSensitivityDefaultedToOne: true
},
needsSave: !alreadyDefaultedToOne || current === undefined
}
}
export function getWorkspaceLayoutHistoryKey(layout: OrcaWorkspaceLayout): string {
return `${normalizeRuntimePathForComparison(layout.path)}:${layout.nestWorkspaces}`
}
export function migrateAgentYoloDefaults(
settings: GlobalSettings | undefined
): Pick<GlobalSettings, 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated'> {
const existingArgs = normalizeTuiAgentArgsRecord(settings?.agentDefaultArgs)
const existingEnv = normalizeTuiAgentEnvRecord(settings?.agentDefaultEnv)
if (settings?.agentYoloDefaultsMigrated === true) {
return {
agentDefaultArgs: existingArgs,
agentDefaultEnv: existingEnv,
agentYoloDefaultsMigrated: true
}
}
const commandOverrides = settings?.agentCmdOverrides ?? {}
const migratedArgs = { ...existingArgs }
for (const [agent, args] of Object.entries(DEFAULT_TUI_AGENT_ARGS)) {
if (agent in migratedArgs) {
continue
}
if (agent in commandOverrides) {
migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = ''
continue
}
migratedArgs[agent as keyof typeof DEFAULT_TUI_AGENT_ARGS] = args
}
const migratedEnv = { ...existingEnv }
for (const [agent, env] of Object.entries(DEFAULT_TUI_AGENT_ENV)) {
if (agent in migratedEnv) {
continue
}
if (agent in commandOverrides) {
migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = {}
continue
}
migratedEnv[agent as keyof typeof DEFAULT_TUI_AGENT_ENV] = { ...env }
}
return {
// Why: legacy users could only customize launch defaults via command overrides, so those agents count as already user-owned.
agentDefaultArgs: migratedArgs,
agentDefaultEnv: migratedEnv,
agentYoloDefaultsMigrated: true
}
}
@@ -0,0 +1,102 @@
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceLineage } from '../../../shared/worktree/lineage-types'
import { normalizeFeatureInteractions } from '../../../shared/feature-interactions'
import { normalizeContextualTourIds } from '../../../shared/contextual-tours'
import { isWorkspaceKey } from '../../../shared/workspace-scope'
export function mergeFeatureInteractions(
current: PersistedState['ui']['featureInteractions'],
incoming: PersistedState['ui']['featureInteractions']
): PersistedState['ui']['featureInteractions'] {
const currentNormalized = normalizeFeatureInteractions(current)
const incomingNormalized = normalizeFeatureInteractions(incoming)
const merged = { ...currentNormalized }
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
const currentRecord = currentNormalized[id as keyof typeof currentNormalized]
merged[id as keyof typeof merged] = currentRecord
? {
firstInteractedAt: Math.min(
currentRecord.firstInteractedAt,
incomingRecord.firstInteractedAt
),
interactionCount: Math.max(
currentRecord.interactionCount,
incomingRecord.interactionCount
)
}
: incomingRecord
}
return merged
}
export function mergeContextualTourSeenIds(
current: PersistedState['ui']['contextualToursSeenIds'],
incoming: PersistedState['ui']['contextualToursSeenIds']
): PersistedState['ui']['contextualToursSeenIds'] {
const merged = new Set(normalizeContextualTourIds(current))
for (const id of normalizeContextualTourIds(incoming)) {
merged.add(id)
}
return [...merged]
}
export function stripMainOwnedTelemetryMarkerFromUI(
value: Partial<PersistedState['ui']> | undefined
): Partial<PersistedState['ui']> {
if (!value || typeof value !== 'object') {
return {}
}
const { featureInteractionTelemetryBuckets: _reserved, ...ui } = value as Partial<
PersistedState['ui']
> & {
featureInteractionTelemetryBuckets?: unknown
}
void _reserved
return ui
}
export function normalizeWorkspaceLineageByChildKey(
value: unknown
): Record<WorkspaceKey, WorkspaceLineage> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const normalized: Record<WorkspaceKey, WorkspaceLineage> = {}
for (const [key, entry] of Object.entries(value)) {
if (!isWorkspaceKey(key) || !entry || typeof entry !== 'object') {
continue
}
const lineage = entry as Partial<WorkspaceLineage>
const childWorkspaceKey =
typeof lineage.childWorkspaceKey === 'string' && isWorkspaceKey(lineage.childWorkspaceKey)
? lineage.childWorkspaceKey
: key
const parentWorkspaceKey = lineage.parentWorkspaceKey
if (
!isWorkspaceKey(childWorkspaceKey) ||
typeof parentWorkspaceKey !== 'string' ||
!isWorkspaceKey(parentWorkspaceKey) ||
childWorkspaceKey !== key ||
childWorkspaceKey === parentWorkspaceKey
) {
continue
}
normalized[childWorkspaceKey] = {
childWorkspaceKey,
childInstanceId: lineage.childInstanceId ?? null,
parentWorkspaceKey,
parentInstanceId: lineage.parentInstanceId ?? null,
origin: lineage.origin ?? 'cli',
capture: lineage.capture ?? { source: 'manual-action', confidence: 'inferred' },
...(lineage.taskId ? { taskId: lineage.taskId } : {}),
...(lineage.orchestrationRunId ? { orchestrationRunId: lineage.orchestrationRunId } : {}),
...(lineage.coordinatorHandle ? { coordinatorHandle: lineage.coordinatorHandle } : {}),
...(lineage.createdByTerminalHandle
? { createdByTerminalHandle: lineage.createdByTerminalHandle }
: {}),
createdAt: Number.isFinite(lineage.createdAt) ? Number(lineage.createdAt) : Date.now()
}
}
return normalized
}
@@ -0,0 +1,95 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getDefaultUIState } from '../../../shared/constants'
import { isPluginPanelTabKey } from '../../../shared/plugins/plugin-manifest'
export function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] {
if (
groupBy === 'none' ||
groupBy === 'workspace-status' ||
groupBy === 'repo' ||
groupBy === 'pr-status'
) {
return groupBy
}
if (groupBy === 'flat') {
return 'none'
}
return getDefaultUIState().groupBy
}
export function normalizeShowDotfilesByWorktree(value: unknown): Record<string, boolean> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const out: Record<string, boolean> = {}
for (const [worktreeId, showDotfiles] of Object.entries(value as Record<string, unknown>)) {
if (
!worktreeId ||
worktreeId === '__proto__' ||
worktreeId === 'constructor' ||
worktreeId === 'prototype' ||
typeof showDotfiles !== 'boolean'
) {
continue
}
out[worktreeId] = showDotfiles
}
return out
}
export function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] {
if (
sortBy === 'smart' ||
sortBy === 'recent' ||
sortBy === 'repo' ||
sortBy === 'name' ||
sortBy === 'manual'
) {
return sortBy
}
return getDefaultUIState().sortBy
}
export function normalizeProjectOrderBy(
projectOrderBy: unknown
): PersistedState['ui']['projectOrderBy'] {
if (projectOrderBy === 'manual' || projectOrderBy === 'recent') {
return projectOrderBy
}
return getDefaultUIState().projectOrderBy
}
export function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
if (
tab === 'explorer' ||
tab === 'search' ||
tab === 'vault' ||
tab === 'workspaces' ||
tab === 'pr-checks' ||
tab === 'source-control' ||
tab === 'checks' ||
tab === 'ports'
) {
return tab
}
// Why: plugin tabs are open-ended `plugin:<publisher>.<id>/<panel>` keys; validate the
// shape so a persisted plugin tab doesn't reset to Explorer on restart.
if (typeof tab === 'string' && isPluginPanelTabKey(tab)) {
return tab
}
return getDefaultUIState().rightSidebarTab
}
export function normalizeRightSidebarExplorerView(
view: unknown,
tab?: unknown
): PersistedState['ui']['rightSidebarExplorerView'] {
// Why: older builds persisted Search as a standalone activity tab.
if (tab === 'search') {
return 'search'
}
if (view === 'files' || view === 'search') {
return view
}
return getDefaultUIState().rightSidebarExplorerView
}
@@ -0,0 +1,74 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import {
getDefaultUIState,
normalizeAgentActivityDisplayMode,
normalizeWorktreeCardProperties
} from '../../../shared/constants'
import {
normalizeWorkspaceStatuses,
clampWorkspaceBoardColumnWidth,
clampWorkspaceBoardOpacity
} from '../../../shared/workspace-statuses'
import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display'
import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode'
import { clampMarkdownTocPanelWidth } from '../../../shared/markdown-toc-panel-width'
import { clampCombinedDiffFileTreeWidth } from '../../../shared/combined-diff-file-tree-width'
import {
normalizeVisibleExecutionHostIds,
normalizeExecutionHostOrder
} from '../../../shared/execution-host'
import { normalizeManualRepoOrder } from '../../../shared/manual-repo-order'
import { normalizeBrowserPageZoomLevel } from '../../../shared/browser-page-zoom'
import { normalizeFeatureTipIds } from '../../../shared/feature-tips'
import { normalizeContextualTourIds } from '../../../shared/contextual-tours'
import { normalizeFeatureInteractions } from '../../../shared/feature-interactions'
import {
normalizeGroupBy,
normalizeProjectOrderBy,
normalizeRightSidebarExplorerView,
normalizeRightSidebarTab,
normalizeShowDotfilesByWorktree,
normalizeSortBy
} from './ui-selection-normalization'
import { stripMainOwnedTelemetryMarkerFromUI } from './ui-interaction-merge'
export function getPersistedUI(
state: PersistedState,
activeView: PersistedState['ui']['activeView']
): PersistedState['ui'] {
const uiState = stripMainOwnedTelemetryMarkerFromUI(state.ui)
return {
...getDefaultUIState(),
...uiState,
groupBy: normalizeGroupBy(state.ui?.groupBy),
sortBy: normalizeSortBy(state.ui?.sortBy),
projectOrderBy: normalizeProjectOrderBy(state.ui?.projectOrderBy),
rightSidebarTab: normalizeRightSidebarTab(state.ui?.rightSidebarTab),
rightSidebarExplorerView: normalizeRightSidebarExplorerView(
state.ui?.rightSidebarExplorerView,
state.ui?.rightSidebarTab
),
worktreeCardProperties: normalizeWorktreeCardProperties(state.ui?.worktreeCardProperties),
agentActivityDisplayMode: normalizeAgentActivityDisplayMode(state.ui?.agentActivityDisplayMode),
workspaceStatuses: normalizeWorkspaceStatuses(state.ui?.workspaceStatuses),
workspaceBoardOpacity: clampWorkspaceBoardOpacity(state.ui?.workspaceBoardOpacity),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(state.ui?.workspaceBoardColumnWidth),
syncTaskStatusFromWorkspaceBoard: state.ui?.syncTaskStatusFromWorkspaceBoard === true,
usagePercentageDisplay: normalizeUsagePercentageDisplay(state.ui?.usagePercentageDisplay),
statusBarUsageMode: normalizeStatusBarUsageMode(state.ui?.statusBarUsageMode),
// Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires).
trayMinimizeNoticeShown: state.ui?.trayMinimizeNoticeShown === true,
osc52ClipboardDefaultOnNoticePending: state.ui?.osc52ClipboardDefaultOnNoticePending === true,
markdownTocPanelWidth: clampMarkdownTocPanelWidth(state.ui?.markdownTocPanelWidth),
combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth(state.ui?.combinedDiffFileTreeWidth),
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(state.ui?.visibleWorkspaceHostIds),
workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder),
manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder),
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel),
showDotfilesByWorktree: normalizeShowDotfilesByWorktree(state.ui?.showDotfilesByWorktree),
featureTipsSeenIds: normalizeFeatureTipIds(state.ui?.featureTipsSeenIds),
contextualToursSeenIds: normalizeContextualTourIds(state.ui?.contextualToursSeenIds),
featureInteractions: normalizeFeatureInteractions(state.ui?.featureInteractions),
activeView: activeView
}
}
@@ -0,0 +1,201 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import {
getDefaultUIState,
normalizeAgentActivityDisplayMode,
normalizeWorktreeCardProperties
} from '../../../shared/constants'
import {
normalizeWorkspaceStatuses,
clampWorkspaceBoardColumnWidth,
clampWorkspaceBoardOpacity
} from '../../../shared/workspace-statuses'
import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display'
import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode'
import { clampMarkdownTocPanelWidth } from '../../../shared/markdown-toc-panel-width'
import { clampCombinedDiffFileTreeWidth } from '../../../shared/combined-diff-file-tree-width'
import {
normalizeVisibleExecutionHostIds,
normalizeExecutionHostOrder
} from '../../../shared/execution-host'
import { normalizeManualRepoOrder } from '../../../shared/manual-repo-order'
import { normalizeBrowserPageZoomLevel } from '../../../shared/browser-page-zoom'
import { normalizeFeatureTipIds } from '../../../shared/feature-tips'
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
} from '../../protected-secret-persistence'
import {
normalizeGroupBy,
normalizeProjectOrderBy,
normalizeRightSidebarExplorerView,
normalizeRightSidebarTab,
normalizeShowDotfilesByWorktree,
normalizeSortBy
} from './ui-selection-normalization'
import {
mergeContextualTourSeenIds,
mergeFeatureInteractions,
stripMainOwnedTelemetryMarkerFromUI
} from './ui-interaction-merge'
export type UIUpdateOperations = {
state: StoreOwnedPersistedState
removeRetainedBlob: (
slot: Parameters<ProtectedSecretPersistence['removeRetainedBlob']>[0]
) => void
setActiveView: (activeView: PersistedState['ui']['activeView'] | undefined) => boolean
getUI: () => PersistedState['ui']
scheduleSave: () => void
notifyUIChanged: () => void
}
export function updatePersistedUI(
operations: UIUpdateOperations,
updates: Partial<PersistedState['ui']>
): void {
if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) {
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink)
}
const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates)
const { activeView, ...durableUpdates } = sanitizedUpdates
const activeViewChanged = operations.setActiveView(activeView)
if (Object.keys(durableUpdates).length === 0) {
if (activeViewChanged) {
operations.notifyUIChanged()
}
return
}
const currentUI = {
...getDefaultUIState(),
...stripMainOwnedTelemetryMarkerFromUI(operations.state.ui)
}
const previousUI = {
...operations.getUI(),
// Why: the legacy field stays unchanged as a migration/downgrade
// fallback; the profile sidecar is authoritative in current builds.
activeView: currentUI.activeView
}
const nextRightSidebarTab =
sanitizedUpdates.rightSidebarTab !== undefined
? normalizeRightSidebarTab(sanitizedUpdates.rightSidebarTab)
: normalizeRightSidebarTab(operations.state.ui?.rightSidebarTab)
const nextRightSidebarExplorerView =
sanitizedUpdates.rightSidebarExplorerView !== undefined
? normalizeRightSidebarExplorerView(
sanitizedUpdates.rightSidebarExplorerView,
nextRightSidebarTab
)
: sanitizedUpdates.rightSidebarTab === 'search'
? 'search'
: normalizeRightSidebarExplorerView(
operations.state.ui?.rightSidebarExplorerView,
nextRightSidebarTab
)
const nextUI = {
...currentUI,
...durableUpdates,
workspaceCleanup: mergeWorkspaceCleanupUIState(
currentUI.workspaceCleanup,
durableUpdates.workspaceCleanup
),
groupBy: durableUpdates.groupBy
? normalizeGroupBy(durableUpdates.groupBy)
: normalizeGroupBy(operations.state.ui?.groupBy),
sortBy: durableUpdates.sortBy
? normalizeSortBy(durableUpdates.sortBy)
: normalizeSortBy(operations.state.ui?.sortBy),
projectOrderBy: sanitizedUpdates.projectOrderBy
? normalizeProjectOrderBy(sanitizedUpdates.projectOrderBy)
: normalizeProjectOrderBy(operations.state.ui?.projectOrderBy),
activeView: currentUI.activeView,
rightSidebarTab: nextRightSidebarTab,
rightSidebarExplorerView: nextRightSidebarExplorerView,
worktreeCardProperties:
sanitizedUpdates.worktreeCardProperties !== undefined
? normalizeWorktreeCardProperties(sanitizedUpdates.worktreeCardProperties)
: normalizeWorktreeCardProperties(operations.state.ui?.worktreeCardProperties),
agentActivityDisplayMode:
sanitizedUpdates.agentActivityDisplayMode !== undefined
? normalizeAgentActivityDisplayMode(sanitizedUpdates.agentActivityDisplayMode)
: normalizeAgentActivityDisplayMode(operations.state.ui?.agentActivityDisplayMode),
workspaceStatuses:
sanitizedUpdates.workspaceStatuses !== undefined
? normalizeWorkspaceStatuses(sanitizedUpdates.workspaceStatuses)
: normalizeWorkspaceStatuses(operations.state.ui?.workspaceStatuses),
workspaceBoardOpacity: clampWorkspaceBoardOpacity(
sanitizedUpdates.workspaceBoardOpacity ?? operations.state.ui?.workspaceBoardOpacity
),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
sanitizedUpdates.workspaceBoardColumnWidth ?? operations.state.ui?.workspaceBoardColumnWidth
),
syncTaskStatusFromWorkspaceBoard:
sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined
? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true
: operations.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
usagePercentageDisplay: normalizeUsagePercentageDisplay(
sanitizedUpdates.usagePercentageDisplay ?? operations.state.ui?.usagePercentageDisplay
),
statusBarUsageMode: normalizeStatusBarUsageMode(
sanitizedUpdates.statusBarUsageMode ?? operations.state.ui?.statusBarUsageMode
),
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
sanitizedUpdates.markdownTocPanelWidth ?? operations.state.ui?.markdownTocPanelWidth
),
combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth(
sanitizedUpdates.combinedDiffFileTreeWidth ?? operations.state.ui?.combinedDiffFileTreeWidth
),
visibleWorkspaceHostIds:
sanitizedUpdates.visibleWorkspaceHostIds !== undefined
? normalizeVisibleExecutionHostIds(sanitizedUpdates.visibleWorkspaceHostIds)
: normalizeVisibleExecutionHostIds(operations.state.ui?.visibleWorkspaceHostIds),
workspaceHostOrder:
sanitizedUpdates.workspaceHostOrder !== undefined
? normalizeExecutionHostOrder(sanitizedUpdates.workspaceHostOrder)
: normalizeExecutionHostOrder(operations.state.ui?.workspaceHostOrder),
manualRepoOrder:
sanitizedUpdates.manualRepoOrder !== undefined
? normalizeManualRepoOrder(sanitizedUpdates.manualRepoOrder)
: normalizeManualRepoOrder(operations.state.ui?.manualRepoOrder),
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(
sanitizedUpdates.browserDefaultZoomLevel ?? operations.state.ui?.browserDefaultZoomLevel
),
showDotfilesByWorktree:
sanitizedUpdates.showDotfilesByWorktree !== undefined
? normalizeShowDotfilesByWorktree(sanitizedUpdates.showDotfilesByWorktree)
: normalizeShowDotfilesByWorktree(operations.state.ui?.showDotfilesByWorktree),
featureTipsSeenIds:
sanitizedUpdates.featureTipsSeenIds !== undefined
? normalizeFeatureTipIds(sanitizedUpdates.featureTipsSeenIds)
: normalizeFeatureTipIds(operations.state.ui?.featureTipsSeenIds),
// Why: renderer and paired clients can mark different tours seen from stale snapshots; union so completed tours stay suppressed.
contextualToursSeenIds:
sanitizedUpdates.contextualToursSeenIds !== undefined
? mergeContextualTourSeenIds(
operations.state.ui?.contextualToursSeenIds,
sanitizedUpdates.contextualToursSeenIds
)
: normalizeContextualTourIds(operations.state.ui?.contextualToursSeenIds),
// Why: runtime RPCs and the renderer both record education state; merge so a stale renderer snapshot can't erase runtime-only interactions.
featureInteractions:
sanitizedUpdates.featureInteractions !== undefined
? mergeFeatureInteractions(
operations.state.ui?.featureInteractions,
sanitizedUpdates.featureInteractions
)
: normalizeFeatureInteractions(operations.state.ui?.featureInteractions)
}
if (persistedUIValuesEqual(previousUI, nextUI)) {
if (activeViewChanged) {
operations.notifyUIChanged()
}
return
}
operations.state.ui = nextUI
operations.scheduleSave()
operations.notifyUIChanged()
}
@@ -0,0 +1,11 @@
export function isLegacyOpenCodeSessionCookie(value: string): boolean {
const trimmed = value.trim()
return (
trimmed.startsWith('Fe26.2**') ||
trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim()))
)
}
export function isLegacySshPtyOwnerLease(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
@@ -0,0 +1,126 @@
import type {
SshPtyConsumerRecovery,
SshRemotePtyLease,
SshTarget
} from '../../../shared/ssh-types'
import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../shared/ssh-types'
export type LegacySshTarget = SshTarget & {
remoteWorkspaceSyncEnabled?: unknown
remoteWorkspaceSyncGracePeriodSeconds?: unknown
experimentalPtySourceCreditV1?: unknown
}
// Why: old targets predate configHost; default to label-based lookup so imported SSH aliases still resolve via ssh -G.
export function normalizeSshTarget(t: SshTarget): SshTarget {
const target = { ...(t as LegacySshTarget) }
const legacySyncEnabled = target.remoteWorkspaceSyncEnabled
const currentGracePeriodSeconds = target.relayGracePeriodSeconds
const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds
const systemSshConnectionReuse = target.systemSshConnectionReuse
// Why: remote sync now follows the SSH relay lifecycle, so retired per-target sync/grace fields are dropped at disk load.
delete target.remoteWorkspaceSyncEnabled
delete target.remoteWorkspaceSyncGracePeriodSeconds
delete target.relayGracePeriodSeconds
delete target.systemSshConnectionReuse
delete target.experimentalPtySourceCreditV1
// Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration.
const relayGracePeriodSeconds =
legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number'
? legacyGracePeriodSeconds
: currentGracePeriodSeconds
const normalized: SshTarget = {
...target,
configHost: target.configHost ?? target.label ?? target.host
}
// Why: old SSH form persisted 10800 even without a user choice; treat that legacy default as the new implicit default.
if (
relayGracePeriodSeconds !== undefined &&
relayGracePeriodSeconds !== LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
) {
normalized.relayGracePeriodSeconds = relayGracePeriodSeconds
}
if (systemSshConnectionReuse === false) {
normalized.systemSshConnectionReuse = false
}
return normalized
}
// Why: strict whitelist — a record missing or mistyping a required field is dropped rather than partially trusted.
export function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null {
if (!value || typeof value !== 'object') {
return null
}
const raw = value as Partial<SshRemotePtyLease>
if (typeof raw.targetId !== 'string' || typeof raw.ptyId !== 'string') {
return null
}
const state = raw.state ?? 'detached'
if (!['attached', 'detached', 'terminated', 'expired'].includes(state)) {
return null
}
const now = Date.now()
return {
targetId: raw.targetId,
ptyId: raw.ptyId,
...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}),
...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}),
...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}),
state,
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now,
updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now,
...(typeof raw.lastAttachedAt === 'number' ? { lastAttachedAt: raw.lastAttachedAt } : {}),
...(typeof raw.lastDetachedAt === 'number' ? { lastDetachedAt: raw.lastDetachedAt } : {})
}
}
export const SSH_PTY_OWNER_LEASE_MAX_LENGTH = 512
export const ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH = 4096
export function normalizeSshPtyConsumerRecovery(
value: unknown,
ownerLeaseMaxLength = SSH_PTY_OWNER_LEASE_MAX_LENGTH
): SshPtyConsumerRecovery | null {
if (!value || typeof value !== 'object') {
return null
}
const raw = value as Partial<SshPtyConsumerRecovery>
const clientGeneration = raw.clientGeneration
const ownerGeneration = raw.ownerGeneration
if (
typeof raw.targetId !== 'string' ||
raw.targetId.length === 0 ||
raw.targetId.length > 512 ||
typeof raw.clientInstanceId !== 'string' ||
raw.clientInstanceId.length === 0 ||
raw.clientInstanceId.length > 512 ||
typeof raw.serverBuildId !== 'string' ||
raw.serverBuildId.length === 0 ||
raw.serverBuildId.length > 512 ||
typeof clientGeneration !== 'number' ||
!Number.isSafeInteger(clientGeneration) ||
clientGeneration <= 0 ||
typeof ownerGeneration !== 'number' ||
!Number.isSafeInteger(ownerGeneration) ||
ownerGeneration <= 0 ||
typeof raw.ownerLease !== 'string' ||
raw.ownerLease.length === 0 ||
raw.ownerLease.length > ownerLeaseMaxLength
) {
return null
}
const flow = raw.outputFlowControl
const outputFlowControl =
flow?.version === 1 && Number.isSafeInteger(flow.windowSu) && flow.windowSu > 0
? { version: 1 as const, windowSu: flow.windowSu }
: undefined
return {
targetId: raw.targetId,
clientInstanceId: raw.clientInstanceId,
serverBuildId: raw.serverBuildId,
clientGeneration,
ownerGeneration,
ownerLease: raw.ownerLease,
...(outputFlowControl ? { outputFlowControl } : {})
}
}
@@ -0,0 +1,117 @@
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'
export type SshPtyBindingCleanupOperations = {
state: StoreOwnedPersistedState
toComparablePtyId: (targetId: string, ptyId: string) => string
scheduleSave: () => void
}
function sshRemotePtyLeaseMayReferenceBinding(
operations: SshPtyBindingCleanupOperations,
lease: SshRemotePtyLease,
binding: {
ptyId: string
targetId: string
worktreeId?: string
tabId?: string
leafId?: string
}
): boolean {
const bindingPtyId = operations.toComparablePtyId(binding.targetId, binding.ptyId)
if (lease.targetId !== binding.targetId || lease.ptyId !== bindingPtyId) {
return false
}
// Why: target removal is destructive; scrub matching bindings before deleting the lease, else removing the tombstone can revive stale PTY ids.
return (
(binding.worktreeId === undefined ||
lease.worktreeId === undefined ||
lease.worktreeId === binding.worktreeId) &&
(binding.tabId === undefined || lease.tabId === undefined || lease.tabId === binding.tabId) &&
(binding.leafId === undefined || lease.leafId === undefined || lease.leafId === binding.leafId)
)
}
export function clearSshRemotePtyBindingsForTarget(
operations: SshPtyBindingCleanupOperations,
targetId: string
): void {
const leases = operations.state.sshRemotePtyLeases?.filter((lease) => lease.targetId === targetId)
clearSshRemotePtyBindingsForLeases(operations, targetId, leases ?? [])
}
export function clearSshRemotePtyBindingsForLeases(
operations: SshPtyBindingCleanupOperations,
targetId: string,
leases: SshRemotePtyLease[]
): boolean {
if (!leases?.length) {
return false
}
let changed = false
const sessions = new Set(
[
operations.state.workspaceSession,
operations.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)]
].filter((session): session is WorkspaceSessionState => Boolean(session))
)
for (const session of sessions) {
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (
tab.ptyId &&
leases.some((lease) =>
sshRemotePtyLeaseMayReferenceBinding(operations, lease, {
ptyId: tab.ptyId!,
worktreeId,
targetId,
tabId: tab.id
})
)
) {
tab.ptyId = null
changed = true
}
}
}
const worktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (!worktreeIdByTabId.has(tab.id)) {
worktreeIdByTabId.set(tab.id, worktreeId)
}
}
}
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const bindings = layout.ptyIdsByLeafId
if (!bindings) {
continue
}
const worktreeId = worktreeIdByTabId.get(tabId)
const nextBindings = Object.fromEntries(
Object.entries(bindings).filter(
([leafId, ptyId]) =>
!leases.some((lease) =>
sshRemotePtyLeaseMayReferenceBinding(operations, lease, {
ptyId,
targetId,
worktreeId,
tabId,
leafId
})
)
)
)
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
layout.ptyIdsByLeafId = nextBindings
changed = true
}
}
}
if (changed) {
operations.scheduleSave()
}
return changed
}
@@ -0,0 +1,69 @@
import type { SshPtyConsumerRecovery } 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 { normalizeSshPtyConsumerRecovery } from './ssh-normalization'
export type SshPtyConsumerRecoveryOperations = {
state: StoreOwnedPersistedState
protectedSecrets: Pick<ProtectedSecretPersistence, 'isSealed' | 'removeRetainedBlob'>
flushDurableStateOrThrowAsync: () => Promise<void>
}
async function flushSshPtyConsumerRecovery(
operations: SshPtyConsumerRecoveryOperations
): Promise<void> {
// Why: ownership must be durable before relay setup continues, but this runs on the live
// establish/reconnect path — a sync flush would park the main thread on a stalled profile mount.
// Why not caught here: the failure must reach the awaiting caller.
await operations.flushDurableStateOrThrowAsync()
}
export function getSshPtyConsumerRecovery(
operations: SshPtyConsumerRecoveryOperations,
targetId: string
): SshPtyConsumerRecovery | null {
const record = (operations.state.sshPtyConsumerRecoveries ?? []).find(
(candidate) => candidate.targetId === targetId
)
if (
record &&
operations.protectedSecrets.isSealed(
sshPtyOwnerLeaseSecretSlot(record.targetId),
record.ownerLease
)
) {
return null
}
return record ? structuredClone(record) : null
}
export async function upsertSshPtyConsumerRecovery(
operations: SshPtyConsumerRecoveryOperations,
record: SshPtyConsumerRecovery
): Promise<void> {
const normalized = normalizeSshPtyConsumerRecovery(record)
if (!normalized) {
throw new Error('Invalid SSH PTY consumer recovery record')
}
const recoveries = operations.state.sshPtyConsumerRecoveries ?? []
operations.state.sshPtyConsumerRecoveries = [
...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId),
normalized
]
await flushSshPtyConsumerRecovery(operations)
}
export async function removeSshPtyConsumerRecovery(
operations: SshPtyConsumerRecoveryOperations,
targetId: string
): Promise<void> {
const recoveries = operations.state.sshPtyConsumerRecoveries ?? []
const next = recoveries.filter((record) => record.targetId !== targetId)
if (next.length === recoveries.length) {
return
}
operations.state.sshPtyConsumerRecoveries = next
operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId))
await flushSshPtyConsumerRecovery(operations)
}
@@ -0,0 +1,204 @@
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
toStoredPtyId: (targetId: string, ptyId: string) => string
clearBindingsForTarget: (targetId: string) => void
clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean
flush: () => void
flushDurableStateOrThrowAsync: () => Promise<void>
}
export function getSshRemotePtyLeases(
state: PersistedState,
targetId?: string
): SshRemotePtyLease[] {
const leases = state.sshRemotePtyLeases ?? []
return leases.filter((lease) => targetId === undefined || lease.targetId === targetId)
}
export function upsertSshRemotePtyLease(
operations: SshPtyLeaseOperations,
lease: Omit<SshRemotePtyLease, 'createdAt' | 'updatedAt'> &
Partial<Pick<SshRemotePtyLease, 'createdAt' | 'updatedAt'>>
): void {
operations.state.sshRemotePtyLeases ??= []
const normalizedLease = { ...lease }
if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) {
delete normalizedLease.leafId
}
// Why: store target-local pty ids in leases so reconnect can call relay pty.attach with raw ids (app ids are global).
normalizedLease.ptyId = operations.toStoredPtyId(normalizedLease.targetId, normalizedLease.ptyId)
const now = Date.now()
const existingIndex = operations.state.sshRemotePtyLeases.findIndex(
(entry) => entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId
)
const existing =
existingIndex !== -1 ? operations.state.sshRemotePtyLeases[existingIndex] : undefined
const next: SshRemotePtyLease = {
...existing,
...normalizedLease,
createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now,
updatedAt: normalizedLease.updatedAt ?? now
}
if (existingIndex !== -1) {
operations.state.sshRemotePtyLeases[existingIndex] = next
} else {
operations.state.sshRemotePtyLeases.push(next)
}
operations.flush()
}
function updateSshRemotePtyLeaseStates(
operations: SshPtyLeaseOperations,
targetId: string,
state: SshRemotePtyLease['state'],
ptyIds?: ReadonlySet<string>
): boolean {
const now = Date.now()
let changed = false
const shouldClearBindings = state === 'terminated' || state === 'expired'
const leasesToClear: SshRemotePtyLease[] = []
operations.state.sshRemotePtyLeases ??= []
for (const lease of operations.state.sshRemotePtyLeases) {
if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) {
continue
}
if (state === 'attached' && (lease.state === 'terminated' || lease.state === 'expired')) {
continue
}
if (state === 'detached' && lease.state !== 'attached') {
continue
}
if (lease.state !== state) {
lease.state = state
lease.updatedAt = now
if (state === 'attached') {
lease.lastAttachedAt = now
} else if (state === 'detached') {
lease.lastDetachedAt = now
}
changed = true
}
if (shouldClearBindings) {
leasesToClear.push(lease)
}
}
const bindingsChanged = shouldClearBindings
? operations.clearBindingsForLeases(targetId, leasesToClear)
: false
return changed || bindingsChanged
}
export function markSshRemotePtyLeases(
operations: SshPtyLeaseOperations,
targetId: string,
state: SshRemotePtyLease['state']
): void {
if (updateSshRemotePtyLeaseStates(operations, targetId, state)) {
operations.flush()
}
}
// Why no write of its own: the committed quit path calls this immediately before the final store
// flush, and that flush is what persists it. A durable write here would race the flush and be
// rejected the moment it latches, which is exactly how an attached lease used to survive quit.
export function markSshRemotePtyLeasesForShutdown(
operations: SshPtyLeaseOperations,
targetId: string,
state: SshRemotePtyLease['state']
): void {
updateSshRemotePtyLeaseStates(operations, targetId, state)
}
export async function markSshRemotePtyLeasesAsync(
operations: SshPtyLeaseOperations,
targetId: string,
state: SshRemotePtyLease['state']
): Promise<void> {
if (updateSshRemotePtyLeaseStates(operations, targetId, state)) {
await operations.flushDurableStateOrThrowAsync()
}
}
export async function markSshRemotePtyLeasesAttachedAsync(
operations: SshPtyLeaseOperations,
targetId: string,
ptyIds: readonly string[]
): Promise<void> {
const relayPtyIds = new Set(ptyIds.map((ptyId) => operations.toStoredPtyId(targetId, ptyId)))
if (updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds)) {
await operations.flushDurableStateOrThrowAsync()
}
}
export function markSshRemotePtyLease(
operations: SshPtyLeaseOperations,
targetId: string,
ptyId: string,
state: SshRemotePtyLease['state']
): void {
const relayPtyId = operations.toStoredPtyId(targetId, ptyId)
const lease = operations.state.sshRemotePtyLeases?.find(
(entry) => entry.targetId === targetId && entry.ptyId === relayPtyId
)
if (!lease) {
return
}
const shouldClearBindings = state === 'terminated' || state === 'expired'
if (lease.state === state) {
if (shouldClearBindings && operations.clearBindingsForLeases(targetId, [lease])) {
operations.flush()
}
return
}
const now = Date.now()
lease.state = state
lease.updatedAt = now
if (state === 'attached') {
lease.lastAttachedAt = now
} else if (state === 'detached') {
lease.lastDetachedAt = now
}
if (shouldClearBindings) {
operations.clearBindingsForLeases(targetId, [lease])
}
operations.flush()
}
export function removeSshRemotePtyLease(
operations: SshPtyLeaseOperations,
targetId: string,
ptyId: string
): void {
const relayPtyId = operations.toStoredPtyId(targetId, ptyId)
const leases = (operations.state.sshRemotePtyLeases ?? []).filter(
(lease) => lease.targetId === targetId && lease.ptyId === relayPtyId
)
const before = operations.state.sshRemotePtyLeases?.length ?? 0
operations.clearBindingsForLeases(targetId, leases)
operations.state.sshRemotePtyLeases = (operations.state.sshRemotePtyLeases ?? []).filter(
(lease) => lease.targetId !== targetId || lease.ptyId !== relayPtyId
)
if (operations.state.sshRemotePtyLeases.length !== before) {
operations.flush()
}
}
export function removeSshRemotePtyLeases(
operations: SshPtyLeaseOperations,
targetId: string
): void {
operations.state.sshRemotePtyLeases ??= []
operations.clearBindingsForTarget(targetId)
const before = operations.state.sshRemotePtyLeases.length
operations.state.sshRemotePtyLeases = operations.state.sshRemotePtyLeases.filter(
(lease) => lease.targetId !== targetId
)
if (operations.state.sshRemotePtyLeases.length !== before) {
operations.flush()
}
}
@@ -0,0 +1,123 @@
import type { ProjectHostSetup } from '../../../shared/project-types'
import { toSshExecutionHostId } from '../../../shared/execution-host'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import {
migrateUiHostScopeSshTargetId,
migrateWorkspaceSessionSshTargetId
} from '../../ssh/ssh-target-id-migration'
import type { ProtectedSecretPersistence } from '../../protected-secret-persistence'
import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence'
export type SshTargetReassignmentOperations = {
state: StoreOwnedPersistedState
protectedSecrets: Pick<ProtectedSecretPersistence, 'removeRetainedBlob'>
syncProjectHostSetupCompatibilityState: () => void
scheduleSave: () => void
}
/**
* Re-point every repo and worktree meta pinned to a removed SSH target id onto
* a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids.
*/
export function reassignSshTargetId(
operations: SshTargetReassignmentOperations,
oldTargetId: string,
newTargetId: string
): string[] {
if (oldTargetId === newTargetId) {
return []
}
const oldHostId = toSshExecutionHostId(oldTargetId)
const newHostId = toSshExecutionHostId(newTargetId)
const repoIds = new Set<string>()
for (const repo of operations.state.repos) {
const matchesConnection = repo.connectionId === oldTargetId
const matchesHost = repo.executionHostId === oldHostId
if (!matchesConnection && !matchesHost) {
continue
}
if (matchesConnection) {
repo.connectionId = newTargetId
}
// Why: don't stamp executionHostId where it was unset — addRemoteRepoFromPath repos derive the host from connectionId.
if (matchesHost) {
repo.executionHostId = newHostId
}
repoIds.add(repo.id)
}
// Re-point worktree metas whose hostId pointed at the old SSH host.
let metaChanged = false
for (const meta of Object.values(operations.state.worktreeMeta)) {
if (meta.hostId === oldHostId) {
meta.hostId = newHostId
metaChanged = true
}
}
// Why: any carrier still holding the old id later throws `SSH target not found` (STA-1468); migrate them all.
let carrierChanged = migrateWorkspaceSessionSshTargetId(
operations.state.workspaceSession,
oldTargetId,
newTargetId
)
for (const session of Object.values(operations.state.workspaceSessionsByHostId ?? {})) {
if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) {
carrierChanged = true
}
}
// Why: partitions are read by host id; re-key from the removed id to the new one (keep new if it already exists).
const partitions = operations.state.workspaceSessionsByHostId
const oldPartition = partitions?.[oldHostId]
if (partitions && oldPartition) {
delete partitions[oldHostId]
partitions[newHostId] ??= oldPartition
carrierChanged = true
}
if (migrateUiHostScopeSshTargetId(operations.state.ui, oldTargetId, newTargetId)) {
carrierChanged = true
}
for (const lease of operations.state.sshRemotePtyLeases ?? []) {
if (lease.targetId === oldTargetId) {
lease.targetId = newTargetId
carrierChanged = true
}
}
const recoveries = operations.state.sshPtyConsumerRecoveries ?? []
const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId)
if (retainedRecoveries.length !== recoveries.length) {
operations.state.sshPtyConsumerRecoveries = retainedRecoveries
operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId))
carrierChanged = true
}
let setupsChanged = false
const keptSetups: ProjectHostSetup[] = []
for (const setup of operations.state.projectHostSetups) {
if (setup.hostId !== oldHostId) {
keptSetups.push(setup)
continue
}
const duplicate = operations.state.projectHostSetups.some(
(entry) =>
entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId
)
// Why: drop the old ghost row that would violate (projectId, hostId) uniqueness with the re-added host's setup.
if (duplicate) {
setupsChanged = true
continue
}
setup.hostId = newHostId
setup.updatedAt = Date.now()
keptSetups.push(setup)
setupsChanged = true
}
if (setupsChanged) {
operations.state.projectHostSetups = keptSetups
}
// Why: repo-row and host-setup rewrites affect host-setup compatibility; meta-only rewrites don't, so gate the sync here.
if (repoIds.size > 0 || setupsChanged) {
operations.syncProjectHostSetupCompatibilityState()
}
if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) {
operations.scheduleSave()
}
return [...repoIds]
}
@@ -0,0 +1,172 @@
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 {
MAX_CLAUDE_LIVE_PTY_SESSION_IDS,
MAX_REMOVED_SSH_TARGET_TOMBSTONES
} from '../restoring-sessions/pane-alias-normalization'
import { normalizeSshTarget } from './ssh-normalization'
export type SshTargetStateOperations = {
state: StoreOwnedPersistedState
protectedSecrets: Pick<ProtectedSecretPersistence, 'removeRetainedBlob'>
scheduleSave: () => void
flush: () => void
}
export function getSshTargets(state: PersistedState): SshTarget[] {
return (state.sshTargets ?? []).map(normalizeSshTarget)
}
export function getSshTarget(state: PersistedState, id: string): SshTarget | undefined {
const target = state.sshTargets?.find((entry) => entry.id === id)
return target ? normalizeSshTarget(target) : undefined
}
export function addSshTarget(operations: SshTargetStateOperations, target: SshTarget): void {
operations.state.sshTargets ??= []
operations.state.sshTargets.push(normalizeSshTarget(target))
operations.scheduleSave()
}
export function updateSshTarget(
operations: SshTargetStateOperations,
id: string,
updates: Partial<Omit<SshTarget, 'id'>>
): SshTarget | null {
const target = operations.state.sshTargets?.find((entry) => entry.id === id)
if (!target) {
return null
}
const normalized = normalizeSshTarget({ ...target, ...updates })
// Why: Object.assign only adds keys, so anything normalization stripped (retired sync fields, implicit defaults) must be deleted off the live target.
const mutableTarget = target as Record<string, unknown>
for (const key of Object.keys(mutableTarget)) {
if (!Object.hasOwn(normalized, key)) {
delete mutableTarget[key]
}
}
Object.assign(target, normalized)
operations.scheduleSave()
return { ...target }
}
export function removeSshTarget(operations: SshTargetStateOperations, id: string): void {
const targets = operations.state.sshTargets ?? []
const recoveries = operations.state.sshPtyConsumerRecoveries ?? []
const nextTargets = targets.filter((target) => target.id !== id)
const nextRecoveries = recoveries.filter((record) => record.targetId !== id)
if (nextTargets.length === targets.length && nextRecoveries.length === recoveries.length) {
return
}
operations.state.sshTargets = nextTargets
operations.state.sshPtyConsumerRecoveries = nextRecoveries
operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id))
operations.scheduleSave()
}
export function getClaudeLivePtySessionIds(state: PersistedState): string[] {
return [...(state.claudeLivePtySessionIds ?? [])]
}
export function addClaudeLivePtySessionId(
operations: SshTargetStateOperations,
sessionId: string
): void {
if (sessionId.length === 0 || sessionId.length > 512) {
return
}
const ids = operations.state.claudeLivePtySessionIds ?? []
if (ids.includes(sessionId)) {
return
}
// Why: drop oldest at the cap — stale ids get pruned against the daemon at startup, so only recency matters.
operations.state.claudeLivePtySessionIds = [...ids, sessionId].slice(
-MAX_CLAUDE_LIVE_PTY_SESSION_IDS
)
// Why: flush sync so a force-quit right after a Claude spawn still seeds the live-PTY gate next launch.
operations.flush()
}
export function removeClaudeLivePtySessionId(
operations: SshTargetStateOperations,
sessionId: string
): void {
const ids = operations.state.claudeLivePtySessionIds ?? []
if (!ids.includes(sessionId)) {
return
}
operations.state.claudeLivePtySessionIds = ids.filter((id) => id !== sessionId)
operations.scheduleSave()
}
export function getDeletedSshConfigAliases(state: PersistedState): string[] {
return [...(state.deletedSshConfigAliases ?? [])]
}
export function addDeletedSshConfigAlias(
operations: SshTargetStateOperations,
alias: string
): void {
operations.state.deletedSshConfigAliases ??= []
if (!operations.state.deletedSshConfigAliases.includes(alias)) {
operations.state.deletedSshConfigAliases.push(alias)
operations.scheduleSave()
}
}
export function removeDeletedSshConfigAlias(
operations: SshTargetStateOperations,
alias: string
): void {
const current = operations.state.deletedSshConfigAliases
if (!current || !current.includes(alias)) {
return
}
operations.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias)
operations.scheduleSave()
}
export function clearDeletedSshConfigAliases(operations: SshTargetStateOperations): void {
if (
operations.state.deletedSshConfigAliases &&
operations.state.deletedSshConfigAliases.length > 0
) {
operations.state.deletedSshConfigAliases = []
operations.scheduleSave()
}
}
export function getRemovedSshTargetTombstones(state: PersistedState): RemovedSshTargetTombstone[] {
return [...(state.removedSshTargetTombstones ?? [])]
}
export function addRemovedSshTargetTombstone(
operations: SshTargetStateOperations,
tombstone: RemovedSshTargetTombstone
): void {
const existing = operations.state.removedSshTargetTombstones ?? []
// Why: dedupe by oldTargetId so re-removing the same id can't stack duplicate tombstones; newest wins.
const filtered = existing.filter((entry) => entry.oldTargetId !== tombstone.oldTargetId)
// Cap the history so pathological churn can't grow the state file unbounded.
operations.state.removedSshTargetTombstones = [...filtered, tombstone].slice(
-MAX_REMOVED_SSH_TARGET_TOMBSTONES
)
operations.scheduleSave()
}
export function removeRemovedSshTargetTombstone(
operations: SshTargetStateOperations,
oldTargetId: string
): void {
const existing = operations.state.removedSshTargetTombstones
if (!existing?.some((entry) => entry.oldTargetId === oldTargetId)) {
return
}
operations.state.removedSshTargetTombstones = existing.filter(
(entry) => entry.oldTargetId !== oldTargetId
)
operations.scheduleSave()
}
@@ -0,0 +1,8 @@
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { hardenExistingSecureFile } from '../../../shared/secure-file'
import { MOBILE_PAIRING_USERDATA_FILES } from '../../runtime/mobile-pairing-files'
// Why capture once (not a module const, not per-call): a const resolves before configureDevUserDataPath() redirects userData (dev/prod collide);
// per-call resolves after app.setName('Orca') flips path case and loses data on case-sensitive FS. index.ts calls initDataPath() at the right moment.
let _dataFile: string | null = null
let _userDataDir: string | null = null
export function initDataPath(): void {
const userDataDir = app.getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
export function getDataFile(): string {
if (!_dataFile) {
// Safety fallback — should not be hit in normal startup.
const userDataDir = app.getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
return _dataFile
}
// Why a sidecar: githubCache refreshes every poll and would rewrite the whole multi-MB orca-data.json each cycle.
// Snapshotted best-effort at quit for instant badges next launch; safe to lose.
export function getGithubCacheFile(dataFile = getDataFile()): string {
return join(dirname(dataFile), 'orca-github-cache.json')
}
export function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null {
try {
const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown
const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
if (
isPlainRecord(parsed) &&
isPlainRecord((parsed as { pr?: unknown }).pr) &&
isPlainRecord((parsed as { issue?: unknown }).issue)
) {
return parsed as PersistedState['githubCache']
}
} catch {
// Missing or corrupt snapshot: start with an empty cache and refetch.
}
return null
}
/**
* Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves.
*
* Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices.
*/
export function getCanonicalUserDataPath(): string {
if (!_userDataDir) {
// Safety fallback — should not be hit in normal startup.
_userDataDir = app.getPath('userData')
}
return _userDataDir
}
/**
* Copy legacy mobile pairing credentials into the canonical userData directory.
*
* Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key.
*/
export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void {
const targetUserDataDir = getCanonicalUserDataPath()
if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) {
return
}
const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({
sourcePath: join(sourceUserDataDir, fileName),
targetPath: join(targetUserDataDir, fileName)
}))
if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) {
return
}
if (migrations.some(({ targetPath }) => existsSync(targetPath))) {
return
}
mkdirSync(targetUserDataDir, { recursive: true })
const copied: string[] = []
try {
for (const { sourcePath, targetPath } of migrations) {
copyFileSync(sourcePath, targetPath)
copied.push(targetPath)
// Why: copyFileSync drops Windows ACLs, so re-assert current-user-only on these credential copies (device tokens, E2EE key).
hardenExistingSecureFile(targetPath)
}
} catch (error) {
// Why: a half-copied pair mixes devices with the wrong key, and the existing-target guard above would block the retry.
for (const targetPath of copied) {
try {
rmSync(targetPath, { force: true })
} catch {
// Best effort — leave the retry guard to the next launch.
}
}
console.error('[persistence] Failed to migrate mobile pairing files forward:', error)
}
}
@@ -0,0 +1,72 @@
import { realpathSync, statSync } from 'node:fs'
import { isAbsolute, join, resolve, sep } from 'node:path'
export function expandFloatingWorkspaceHomePath(input: string, home: string): string {
if (input === '~') {
return home
}
if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) {
return join(home, input.slice(2))
}
return input
}
export function resolveFloatingWorkspacePath(input: string, home: string): string {
const expanded = expandFloatingWorkspaceHomePath(input, home)
return isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded)
}
export function canonicalizePersistedFloatingWorkspaceDirectory(
input: string,
home: string
): string | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
try {
const canonicalPath = resolve(realpathSync(resolveFloatingWorkspacePath(trimmed, home)))
return statSync(canonicalPath).isDirectory() ? canonicalPath : null
} catch {
return null
}
}
export function normalizeFloatingWorkspaceTrustedCwds(
input: unknown,
home: string
): { trustedCwds: string[]; changed: boolean } {
const rawTrustedCwds = Array.isArray(input) ? input : []
const trustedCwds: string[] = []
const seen = new Set<string>()
let changed = input !== undefined && !Array.isArray(input)
for (const rawTrustedCwd of rawTrustedCwds) {
if (typeof rawTrustedCwd !== 'string') {
changed = true
continue
}
const trimmedTrustedCwd = rawTrustedCwd.trim()
if (!trimmedTrustedCwd) {
changed = true
continue
}
const canonicalPath = canonicalizePersistedFloatingWorkspaceDirectory(trimmedTrustedCwd, home)
const normalizedPath = canonicalPath ?? resolveFloatingWorkspacePath(trimmedTrustedCwd, home)
if (!normalizedPath) {
changed = true
continue
}
if (seen.has(normalizedPath)) {
changed = true
continue
}
seen.add(normalizedPath)
trustedCwds.push(normalizedPath)
if (rawTrustedCwd !== normalizedPath) {
changed = true
}
}
return { trustedCwds, changed }
}
@@ -0,0 +1,86 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { ProjectGroup } from '../../../shared/project-group-types'
import type { Repo } from '../../../shared/repo-types'
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
import { getProjectGroupSubtreeIds } from '../../../shared/project-groups'
export function inferFolderScopeConnectionIdForMigration(args: {
folderPath: string
projectGroupId: string
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
}): string | null {
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
const candidateRepos =
groupRepos.length > 0
? groupRepos
: args.repos.filter((repo) => isPathInsideOrEqual(args.folderPath, repo.path))
if (candidateRepos.length === 0) {
return null
}
let hasLocalRepo = false
const connectionIds = new Set<string>()
for (const repo of candidateRepos) {
if (repo.connectionId) {
connectionIds.add(repo.connectionId)
} else {
hasLocalRepo = true
}
}
if (hasLocalRepo || connectionIds.size !== 1) {
return null
}
return [...connectionIds][0]
}
export function backfillFolderScopeConnectionIds(state: PersistedState): {
state: PersistedState
changed: boolean
} {
const groups = state.projectGroups ?? []
const repos = state.repos ?? []
let changed = false
const projectGroups = groups.map((group) => {
if (group.connectionId || !group.parentPath) {
return group
}
const connectionId = inferFolderScopeConnectionIdForMigration({
folderPath: group.parentPath,
projectGroupId: group.id,
projectGroups: groups,
repos
})
if (!connectionId) {
return group
}
changed = true
return { ...group, connectionId }
})
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => {
if (workspace.connectionId) {
return workspace
}
const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null
const connectionId =
groupConnectionId ??
inferFolderScopeConnectionIdForMigration({
folderPath: workspace.folderPath,
projectGroupId: workspace.projectGroupId,
projectGroups,
repos
})
if (!connectionId) {
return workspace
}
changed = true
return { ...workspace, connectionId }
})
return {
changed,
state: changed ? { ...state, projectGroups, folderWorkspaces } : state
}
}
@@ -0,0 +1,246 @@
import { randomUUID } from 'node:crypto'
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import { normalizeFolderWorkspaceName } from '../../../shared/folder-workspaces'
import { getNextProjectGroupOrder } from '../../../shared/project-groups'
import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context'
import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { removeWorkspaceSessionOwner } from './session-owner-removal'
export type FolderWorkspaceMutationOperations = {
state: StoreOwnedPersistedState
scheduleSave: () => void
removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void
pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void
hydrateRepo: (repo: Repo) => Repo
}
export class FolderWorkspacePersistenceOperations {
constructor(private readonly operations: FolderWorkspaceMutationOperations) {}
private get state(): PersistedState {
return this.operations.state
}
private scheduleSave(): void {
this.operations.scheduleSave()
}
private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void {
this.operations.removeWorkspaceLineageForFolderParent(folderWorkspaceId)
}
private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void {
this.operations.pruneMobileClientTabSelections(matchesWorktreeId)
}
private hydrateRepo(repo: Repo): Repo {
return this.operations.hydrateRepo(repo)
}
getFolderWorkspaces(): FolderWorkspace[] {
return [...(this.state.folderWorkspaces ?? [])].sort(
(left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name)
)
}
getFolderWorkspace(id: string): FolderWorkspace | undefined {
return (this.state.folderWorkspaces ?? []).find((workspace) => workspace.id === id)
}
createFolderWorkspace(input: {
projectGroupId: string
name?: string
folderPath?: string | null
linkedTask?: FolderWorkspace['linkedTask']
linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext']
connectionId?: string | null
creatorProvenance?: FolderWorkspace['creatorProvenance']
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}): FolderWorkspace {
const group = (this.state.projectGroups ?? []).find(
(entry) => entry.id === input.projectGroupId
)
const folderPath =
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
? input.folderPath
: group?.parentPath
if (!group || !folderPath) {
throw new Error('Folder-backed project group not found.')
}
const now = Date.now()
const linkedTask = normalizeWorkspaceLinkedItem(input.linkedTask)
const sourceContext = normalizeStoredTaskSourceContext(input.linkedTaskSourceContext)
const workspace: FolderWorkspace = {
id: randomUUID(),
projectGroupId: group.id,
name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`),
folderPath,
connectionId: input.connectionId ?? group.connectionId ?? null,
...(input.creatorProvenance ? { creatorProvenance: input.creatorProvenance } : {}),
linkedTask,
linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext)
? sourceContext
: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: now,
...(input.createdWithAgent ? { createdWithAgent: input.createdWithAgent } : {}),
...(input.pendingFirstAgentMessageRename === true && input.createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
lastActivityAt: 0,
createdAt: now,
updatedAt: now
}
this.state.folderWorkspaces = [workspace, ...(this.state.folderWorkspaces ?? [])]
this.scheduleSave()
return workspace
}
updateFolderWorkspace(
id: string,
updates: Partial<
Pick<
FolderWorkspace,
| 'name'
| 'folderPath'
| 'linkedTask'
| 'linkedTaskSourceContext'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
| 'diffComments'
>
>
): FolderWorkspace | null {
const workspace = this.getFolderWorkspace(id)
if (!workspace) {
return null
}
if (updates.name !== undefined) {
workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name)
}
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
workspace.folderPath = updates.folderPath
}
if (updates.linkedTask !== undefined) {
workspace.linkedTask = normalizeWorkspaceLinkedItem(updates.linkedTask)
if (
workspace.linkedTaskSourceContext &&
!isWorkspaceLinkedItemSourceContextMatch(
workspace.linkedTask,
workspace.linkedTaskSourceContext
)
) {
workspace.linkedTaskSourceContext = null
}
}
if (updates.linkedTaskSourceContext !== undefined) {
const linkedTaskSourceContext = normalizeStoredTaskSourceContext(
updates.linkedTaskSourceContext
)
workspace.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
workspace.linkedTask,
linkedTaskSourceContext
)
? linkedTaskSourceContext
: null
}
if (updates.comment !== undefined) {
workspace.comment = updates.comment
}
if (updates.isArchived !== undefined) {
workspace.isArchived = updates.isArchived
}
if (updates.isUnread !== undefined) {
workspace.isUnread = updates.isUnread
}
if (updates.isPinned !== undefined) {
workspace.isPinned = updates.isPinned
}
if (updates.sortOrder !== undefined && Number.isFinite(updates.sortOrder)) {
workspace.sortOrder = updates.sortOrder
}
if (updates.manualOrder !== undefined) {
if (Number.isFinite(updates.manualOrder)) {
workspace.manualOrder = updates.manualOrder
} else {
delete workspace.manualOrder
}
}
if (updates.workspaceStatus !== undefined) {
workspace.workspaceStatus = updates.workspaceStatus
}
if (updates.createdWithAgent !== undefined) {
workspace.createdWithAgent = updates.createdWithAgent
}
if (updates.pendingFirstAgentMessageRename !== undefined) {
workspace.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename
}
if (updates.firstAgentMessageRenameError !== undefined) {
workspace.firstAgentMessageRenameError = updates.firstAgentMessageRenameError
}
if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) {
workspace.lastActivityAt = updates.lastActivityAt
}
if (updates.diffComments !== undefined) {
workspace.diffComments = updates.diffComments
}
workspace.updatedAt = Date.now()
this.scheduleSave()
return workspace
}
removeFolderWorkspace(id: string): boolean {
const before = this.state.folderWorkspaces?.length ?? 0
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
(workspace) => workspace.id !== id
)
if ((this.state.folderWorkspaces?.length ?? 0) === before) {
return false
}
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
folderWorkspaceKey(id)
)!
this.removeWorkspaceLineageForFolderParent(id)
this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id))
this.scheduleSave()
return true
}
moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null {
const repo = this.state.repos.find((entry) => entry.id === repoId)
if (!repo) {
return null
}
const normalizedGroupId =
groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId)
? groupId
: null
const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId)
repo.projectGroupId = normalizedGroupId
repo.projectGroupOrder =
typeof order === 'number' && Number.isFinite(order)
? order
: getNextProjectGroupOrder(siblingRepos, normalizedGroupId)
this.scheduleSave()
return this.hydrateRepo(repo)
}
}
@@ -0,0 +1,198 @@
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types'
import {
isTerminalLeafId,
parseLegacyNumericPaneKey,
parsePaneKey
} from '../../../shared/stable-pane-id'
import { agentHookServer } from '../../agent-hooks/server'
export function legacyMigrationUnsupportedRowsToAliasEntries(
entries: MigrationUnsupportedPtyEntry[]
): LegacyPaneKeyAliasEntry[] {
const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter(
(entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey)
)
const entriesByTabId = new Map<string, MigrationUnsupportedPtyEntry[]>()
for (const entry of normalizedEntries) {
const tabId = entry.tabId
if (!tabId) {
continue
}
entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry])
}
const aliasEntries: LegacyPaneKeyAliasEntry[] = []
for (const [tabId, tabEntries] of entriesByTabId) {
if (tabEntries.length !== 1) {
continue
}
const [entry] = tabEntries
if (!entry.paneKey) {
continue
}
// Why: pre-stable rows lack the old numeric key; only synthesize single-pane aliases when the row is unambiguous.
for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) {
aliasEntries.push({
ptyId: entry.ptyId,
legacyPaneKey,
stablePaneKey: entry.paneKey,
updatedAt: entry.updatedAt
})
}
}
return aliasEntries
}
// Why: bounds a corrupt/bloated persisted list — the gate only needs the few Claude sessions a daemon can keep alive.
export const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200
// Why: bound removed-SSH-target history so remove/re-add churn can't grow the file unbounded.
export const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50
export function normalizeClaudeLivePtySessionIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
// Why: scan newest-first so the cap keeps the most recent ids, matching addClaudeLivePtySessionId's eviction policy.
const ids: string[] = []
for (let index = value.length - 1; index >= 0; index -= 1) {
const entry = value[index]
if (typeof entry !== 'string' || entry.length === 0 || entry.length > 512) {
continue
}
if (!ids.includes(entry)) {
ids.push(entry)
}
if (ids.length >= MAX_CLAUDE_LIVE_PTY_SESSION_IDS) {
break
}
}
return ids.toReversed()
}
export function normalizeMigrationUnsupportedPtyEntries(
value: unknown
): MigrationUnsupportedPtyEntry[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((entry): entry is MigrationUnsupportedPtyEntry => {
if (!entry || typeof entry !== 'object') {
return false
}
const candidate = entry as Partial<MigrationUnsupportedPtyEntry>
return (
typeof candidate.ptyId === 'string' &&
candidate.ptyId.length > 0 &&
(candidate.worktreeId === undefined || typeof candidate.worktreeId === 'string') &&
(candidate.tabId === undefined || typeof candidate.tabId === 'string') &&
(candidate.leafId === undefined || isTerminalLeafId(candidate.leafId)) &&
(candidate.paneKey === undefined || typeof candidate.paneKey === 'string') &&
candidate.reason === 'legacy-numeric-pane-key' &&
(candidate.source === 'local' || candidate.source === 'ssh') &&
Number.isFinite(candidate.updatedAt)
)
})
}
export function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasEntry[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((entry): entry is LegacyPaneKeyAliasEntry => {
if (!entry || typeof entry !== 'object') {
return false
}
const candidate = entry as Partial<LegacyPaneKeyAliasEntry>
if (
typeof candidate.ptyId !== 'string' ||
candidate.ptyId.trim().length === 0 ||
typeof candidate.legacyPaneKey !== 'string' ||
typeof candidate.stablePaneKey !== 'string' ||
!Number.isFinite(candidate.updatedAt)
) {
return false
}
const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey)
const relocatedSource = parsePaneKey(candidate.legacyPaneKey)
const stable = parsePaneKey(candidate.stablePaneKey)
return Boolean(stable && ((legacy && legacy.tabId === stable.tabId) || relocatedSource))
})
}
export function registerPersistedPaneKeyAlias(entry: LegacyPaneKeyAliasEntry): void {
if (parseLegacyNumericPaneKey(entry.legacyPaneKey)) {
agentHookServer.registerPaneKeyAlias(
entry.legacyPaneKey,
entry.stablePaneKey,
entry.ptyId,
entry.updatedAt,
{ overwriteExisting: false }
)
return
}
// Why: detached agents keep their UUID pane key across restarts; restore the physical-to-owner mapping before hook replay.
agentHookServer.transferPaneAuthority(
entry.legacyPaneKey,
entry.stablePaneKey,
entry.ptyId,
entry.updatedAt,
{ authorityVerified: false }
)
}
export function mergeLegacyPaneKeyAliasEntries(
entries: LegacyPaneKeyAliasEntry[]
): LegacyPaneKeyAliasEntry[] {
const byLegacyPaneKey = new Map<string, LegacyPaneKeyAliasEntry>()
for (const entry of normalizeLegacyPaneKeyAliasEntries(entries)) {
const existing = byLegacyPaneKey.get(entry.legacyPaneKey)
if (!existing || existing.updatedAt <= entry.updatedAt) {
byLegacyPaneKey.set(entry.legacyPaneKey, entry)
}
}
return [...byLegacyPaneKey.values()]
}
export function legacyPaneKeyAliasEntriesEqual(
left: LegacyPaneKeyAliasEntry[],
right: LegacyPaneKeyAliasEntry[]
): boolean {
if (left.length !== right.length) {
return false
}
const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry]))
// Why: field-wise, not JSON.stringify — persisted key order differs from freshly built entries and would fake a dirty state.
return left.every((entry) => {
const other = rightByLegacyPaneKey.get(entry.legacyPaneKey)
return (
other !== undefined &&
entry.ptyId === other.ptyId &&
entry.stablePaneKey === other.stablePaneKey &&
entry.updatedAt === other.updatedAt
)
})
}
export function migrationUnsupportedEntriesEqual(
left: MigrationUnsupportedPtyEntry[],
right: MigrationUnsupportedPtyEntry[]
): boolean {
if (left.length !== right.length) {
return false
}
const rightByPtyId = new Map(right.map((entry) => [entry.ptyId, entry]))
return left.every((entry) => {
const other = rightByPtyId.get(entry.ptyId)
return (
other !== undefined &&
entry.worktreeId === other.worktreeId &&
entry.tabId === other.tabId &&
entry.leafId === other.leafId &&
entry.paneKey === other.paneKey &&
entry.reason === other.reason &&
entry.source === other.source &&
entry.updatedAt === other.updatedAt
)
})
}
@@ -0,0 +1,116 @@
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { agentHookServer } from '../../agent-hooks/server'
import { collectLayoutLeafIdsInOrder, firstLayoutLeafId } from './terminal-layout-normalization'
export function findWorktreeIdForTab(
session: WorkspaceSessionState,
tabId: string
): string | undefined {
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
if (tabs.some((tab) => tab.id === tabId)) {
return worktreeId
}
}
return undefined
}
export type PaneIdentityMigrationEntries = {
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
}
export function collectMigrationUnsupportedPtyEntries(args: {
session: WorkspaceSessionState
tabId: string
inputLayout: TerminalLayoutSnapshot
normalizedLayout: TerminalLayoutSnapshot
leafIdByInputLeafId: Map<string, string>
}): PaneIdentityMigrationEntries {
const worktreeId = findWorktreeIdForTab(args.session, args.tabId)
const tab = worktreeId
? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId)
: undefined
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const registeredLegacyPaneKeys = new Set<string>()
const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0
const fallbackPtyId =
!hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined
const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => {
if (!isTerminalLeafId(leafId)) {
return false
}
let paneKey: string
try {
paneKey = makePaneKey(args.tabId, leafId)
} catch {
return false
}
const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1]
if (!numeric) {
return false
}
// Why: PaneManager ids are 1-based; a zero-based alias in split layouts makes tab:1 ambiguous and misroutes panes.
const legacyPaneKey = `${args.tabId}:${numeric}`
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId)
registeredLegacyPaneKeys.add(legacyPaneKey)
if (ptyId) {
legacyPaneKeyAliasEntries.push({
ptyId,
legacyPaneKey,
stablePaneKey: paneKey,
updatedAt: Date.now()
})
return true
}
return false
}
const inputLeafIds = new Set([
...collectLayoutLeafIdsInOrder(args.inputLayout.root),
...Object.keys(args.inputLayout.ptyIdsByLeafId ?? {})
])
for (const inputLeafId of inputLeafIds) {
if (isTerminalLeafId(inputLeafId)) {
continue
}
const leafId = args.leafIdByInputLeafId.get(inputLeafId)
if (leafId) {
registerLegacyAlias(
inputLeafId,
leafId,
args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId
)
}
}
if (tab?.ptyId && !hasLeafPtyBindings) {
const fallbackLeafId =
args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root)
let paneKey: string | undefined
if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) {
try {
paneKey = makePaneKey(args.tabId, fallbackLeafId)
} catch {
// Why: a persisted tabId can be malformed; skip the alias instead of aborting the whole load-time normalization.
}
}
if (paneKey) {
for (const legacyPaneKey of [`${args.tabId}:0`, `${args.tabId}:1`]) {
if (registeredLegacyPaneKeys.has(legacyPaneKey)) {
continue
}
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId)
legacyPaneKeyAliasEntries.push({
ptyId: tab.ptyId,
legacyPaneKey,
stablePaneKey: paneKey,
updatedAt: Date.now()
})
}
}
}
// Why: legacy numeric pane keys are now bridged by aliases, not persisted as restart-required rows.
return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries }
}
@@ -0,0 +1,107 @@
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
export function createMinimalPersistedTerminalTab(args: {
worktreeId: string
tabId: string
ptyId: string
existingTabCount: number
startupCwd?: string
}): TerminalTab {
const ordinal = args.existingTabCount + 1
const defaultTitle = `Terminal ${ordinal}`
return {
id: args.tabId,
ptyId: args.ptyId,
worktreeId: args.worktreeId,
title: defaultTitle,
defaultTitle,
customTitle: null,
color: null,
sortOrder: args.existingTabCount,
createdAt: Date.now(),
...(args.startupCwd ? { startupCwd: args.startupCwd } : {}),
pendingActivationSpawn: true
}
}
export function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSessionState {
return structuredClone(session)
}
// Owner-keyed deletes only; pane-key-scanned collections live in deleteScannedSessionFieldsForOwners so a batch prune scans each once.
export function deleteOwnerKeyedSessionFields(
next: WorkspaceSessionState,
ownerKey: string,
removedTabIds: Set<string>,
options: { advanceTerminalTopologyRevision?: boolean } = {}
): void {
const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? []
if (next.tabsByWorktree) {
delete next.tabsByWorktree[ownerKey]
}
for (const tab of removedTerminalTabs) {
removedTabIds.add(tab.id)
delete next.terminalLayoutsByTabId[tab.id]
if (next.activeTabId === tab.id) {
next.activeTabId = null
}
}
if (options.advanceTerminalTopologyRevision) {
const repoId = getRepoIdFromWorktreeId(ownerKey)
const previousTopologyRevision = next.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
next.terminalTopologyRevisionByRepoId = {
...next.terminalTopologyRevisionByRepoId,
[repoId]: previousTopologyRevision + 1
}
}
if (next.openFilesByWorktree) {
delete next.openFilesByWorktree[ownerKey]
}
if (next.activeFileIdByWorktree) {
delete next.activeFileIdByWorktree[ownerKey]
}
const browserWorkspaces = next.browserTabsByWorktree?.[ownerKey] ?? []
if (next.browserTabsByWorktree) {
delete next.browserTabsByWorktree[ownerKey]
}
if (next.browserPagesByWorkspace) {
for (const workspace of browserWorkspaces) {
delete next.browserPagesByWorkspace[workspace.id]
}
}
if (next.activeBrowserTabIdByWorktree) {
delete next.activeBrowserTabIdByWorktree[ownerKey]
}
if (next.activeTabTypeByWorktree) {
delete next.activeTabTypeByWorktree[ownerKey]
}
if (next.activeTabIdByWorktree) {
delete next.activeTabIdByWorktree[ownerKey]
}
if (next.unifiedTabs) {
delete next.unifiedTabs[ownerKey]
}
if (next.tabGroups) {
delete next.tabGroups[ownerKey]
}
if (next.tabGroupLayouts) {
delete next.tabGroupLayouts[ownerKey]
}
if (next.activeGroupIdByWorktree) {
delete next.activeGroupIdByWorktree[ownerKey]
}
if (next.lastVisitedAtByWorktreeId) {
delete next.lastVisitedAtByWorktreeId[ownerKey]
}
if (next.defaultTerminalTabsAppliedByWorktreeId) {
delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey]
}
if (next.activeWorkspaceKey === ownerKey) {
next.activeWorkspaceKey = null
}
if (next.activeWorktreeId === ownerKey) {
next.activeWorktreeId = null
}
}
@@ -0,0 +1,98 @@
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { cloneWorkspaceSessionState, deleteOwnerKeyedSessionFields } from './session-owner-fields'
// Scans the pane-key-keyed maps and the shutdown list once, removing every entry
// owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose tab
// was removed). Kept separate from the O(1) deletes so a batch prune scans each
// collection a single time regardless of how many owners are being removed.
export function deleteScannedSessionFieldsForOwners(
next: WorkspaceSessionState,
removedTabIds: ReadonlySet<string>,
isRemovedOwner: (worktreeId: string) => boolean
): void {
if (next.terminalPtyIncarnationsByPaneKey) {
next.terminalPtyIncarnationsByPaneKey = Object.fromEntries(
Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => {
const separator = paneKey.lastIndexOf(':')
return separator < 1 || !removedTabIds.has(paneKey.slice(0, separator))
})
)
}
if (next.terminalSurfaceTombstonesByPaneKey) {
next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries(
Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter(
([, tombstone]) => !isRemovedOwner(tombstone.worktreeId)
)
)
}
if (next.sleepingAgentSessionsByPaneKey) {
for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) {
if (isRemovedOwner(record.worktreeId)) {
delete next.sleepingAgentSessionsByPaneKey[paneKey]
}
}
}
next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter(
(worktreeId) => !isRemovedOwner(worktreeId)
)
}
// Remote (ssh:/runtime:) workspace state can exist in both the renderer's local blob and main's host
// partition, because the renderer falls back to 'local' whenever worktree ownership is unresolved.
export function workspaceSessionPartitionIdsForHost(
hostId: string | null | undefined
): ExecutionHostId[] {
const parsed = parseExecutionHostId(hostId)
return parsed && parsed.id !== LOCAL_EXECUTION_HOST_ID
? [LOCAL_EXECUTION_HOST_ID, parsed.id]
: [LOCAL_EXECUTION_HOST_ID]
}
/** The partition the host actually owns; the others are only spill surfaces for it. */
export function workspaceSessionOwnerPartitionForHost(
hostId: string | null | undefined
): ExecutionHostId {
return parseExecutionHostId(hostId)?.id ?? LOCAL_EXECUTION_HOST_ID
}
export function removeWorkspaceSessionOwner(
session: WorkspaceSessionState | undefined,
ownerKey: string,
options: { advanceTerminalTopologyRevision?: boolean } = {}
): WorkspaceSessionState | undefined {
if (!session) {
return session
}
const next = cloneWorkspaceSessionState(session)
const removedTabIds = new Set<string>()
deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds, options)
deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => worktreeId === ownerKey)
return next
}
// Batch variant of removeWorkspaceSessionOwner: prunes every owner in `ownerKeys`
// with a single structuredClone and a single scan of each collection, instead of
// one clone+scan per owner. Project removal can touch many worktrees across many
// host partitions, so the per-owner clones added up to O(worktrees × hosts).
export function removeWorkspaceSessionOwners(
session: WorkspaceSessionState | undefined,
ownerKeys: ReadonlySet<string>
): WorkspaceSessionState | undefined {
if (!session || ownerKeys.size === 0) {
return session
}
const next = cloneWorkspaceSessionState(session)
const removedTabIds = new Set<string>()
for (const ownerKey of ownerKeys) {
deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds)
}
deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) =>
ownerKeys.has(worktreeId)
)
return next
}
@@ -0,0 +1,260 @@
import { randomUUID } from 'node:crypto'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
} from '../../../shared/terminal-tab-types'
import { isTerminalLeafId } from '../../../shared/stable-pane-id'
export type LayoutLeafNormalization = {
snapshot: TerminalLayoutSnapshot
changed: boolean
leafIdByInputLeafId: Map<string, string>
}
export function collectLayoutLeafCounts(
node: TerminalPaneLayoutNode,
counts = new Map<string, number>()
): Map<string, number> {
if (node.type === 'leaf') {
counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1)
return counts
}
collectLayoutLeafCounts(node.first, counts)
collectLayoutLeafCounts(node.second, counts)
return counts
}
export function collectLayoutLeafIdsInOrder(
node: TerminalPaneLayoutNode | null | undefined
): string[] {
if (!node) {
return []
}
if (node.type === 'leaf') {
return [node.leafId]
}
return [...collectLayoutLeafIdsInOrder(node.first), ...collectLayoutLeafIdsInOrder(node.second)]
}
export function firstLayoutLeafId(node: TerminalPaneLayoutNode | null): string | null {
if (!node) {
return null
}
return node.type === 'leaf' ? node.leafId : firstLayoutLeafId(node.first)
}
export function layoutContainsLeafId(node: TerminalPaneLayoutNode | null, leafId: string): boolean {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return layoutContainsLeafId(node.first, leafId) || layoutContainsLeafId(node.second, leafId)
}
export function cloneLayoutNode(node: TerminalPaneLayoutNode): TerminalPaneLayoutNode {
if (node.type === 'leaf') {
return { type: 'leaf', leafId: node.leafId }
}
return {
...node,
first: cloneLayoutNode(node.first),
second: cloneLayoutNode(node.second)
}
}
export function cloneLayoutWithLeafIds(
node: TerminalPaneLayoutNode,
leafIdByInputLeafId: Map<string, string>,
duplicatedInputLeafIds: Set<string>
): TerminalPaneLayoutNode {
if (node.type === 'leaf') {
return {
type: 'leaf',
leafId: duplicatedInputLeafIds.has(node.leafId)
? randomUUID()
: (leafIdByInputLeafId.get(node.leafId) ?? randomUUID())
}
}
return {
...node,
first: cloneLayoutWithLeafIds(node.first, leafIdByInputLeafId, duplicatedInputLeafIds),
second: cloneLayoutWithLeafIds(node.second, leafIdByInputLeafId, duplicatedInputLeafIds)
}
}
export function remapLeafRecordForPersistence(
source: Record<string, string> | undefined,
leafIdByInputLeafId: Map<string, string>,
duplicatedInputLeafIds: Set<string>
): Record<string, string> | undefined {
if (!source) {
return undefined
}
const next: Record<string, string> = {}
for (const [leafId, value] of Object.entries(source)) {
if (duplicatedInputLeafIds.has(leafId)) {
continue
}
const nextLeafId = leafIdByInputLeafId.get(leafId)
if (nextLeafId) {
next[nextLeafId] = value
}
}
return Object.keys(next).length > 0 ? next : undefined
}
export function leafRecordEquivalent(
left: Record<string, string> | undefined,
right: Record<string, string> | undefined
): boolean {
const leftEntries = Object.entries(left ?? {})
const rightRecord = right ?? {}
if (leftEntries.length !== Object.keys(rightRecord).length) {
return false
}
return leftEntries.every(([key, value]) => rightRecord[key] === value)
}
export function preserveMissingLeafRecordEntries(
priorRecord: Record<string, string> | undefined,
incomingRecord: Record<string, string> | undefined,
liveLeafIds: Set<string>
): Record<string, string> | undefined {
const preserved = Object.fromEntries(
Object.entries(priorRecord ?? {}).filter(
([leafId]) => liveLeafIds.has(leafId) && incomingRecord?.[leafId] === undefined
)
)
const next = { ...preserved, ...incomingRecord }
return Object.keys(next).length > 0 ? next : undefined
}
export function normalizeTerminalLayoutSnapshotForPersistence(
snapshot: TerminalLayoutSnapshot,
preferredLayout?: TerminalLayoutSnapshot
): LayoutLeafNormalization {
let inputSnapshot = snapshot
let changed = false
if (!inputSnapshot.root) {
if (!preferredLayout?.root) {
return { snapshot, changed: false, leafIdByInputLeafId: new Map() }
}
const root = cloneLayoutNode(preferredLayout.root)
const rootLeafIds = new Set(collectLayoutLeafIdsInOrder(root))
const activeLeafId =
(inputSnapshot.activeLeafId && rootLeafIds.has(inputSnapshot.activeLeafId)
? inputSnapshot.activeLeafId
: null) ??
(preferredLayout.activeLeafId && rootLeafIds.has(preferredLayout.activeLeafId)
? preferredLayout.activeLeafId
: null) ??
firstLayoutLeafId(root)
const expandedLeafId =
(inputSnapshot.expandedLeafId && rootLeafIds.has(inputSnapshot.expandedLeafId)
? inputSnapshot.expandedLeafId
: null) ??
(preferredLayout.expandedLeafId && rootLeafIds.has(preferredLayout.expandedLeafId)
? preferredLayout.expandedLeafId
: null)
inputSnapshot = { ...inputSnapshot, root, activeLeafId, expandedLeafId }
// Why: a debounced renderer writer can still hold the createTab-era empty layout after the UUID root was sync-flushed.
changed = true
}
const inputRoot = inputSnapshot.root
if (!inputRoot) {
return { snapshot, changed: false, leafIdByInputLeafId: new Map() }
}
const counts = collectLayoutLeafCounts(inputRoot)
const duplicatedInputLeafIds = new Set(
Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([leafId]) => leafId)
)
const inputLeafIdsInOrder = collectLayoutLeafIdsInOrder(inputRoot)
const preferredLeafIdsInOrder = collectLayoutLeafIdsInOrder(preferredLayout?.root)
const usePreferredLeafIds = preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length
const leafIdByInputLeafId = new Map<string, string>()
for (const [index, leafId] of inputLeafIdsInOrder.entries()) {
const count = counts.get(leafId) ?? 0
if (count !== 1 || leafIdByInputLeafId.has(leafId)) {
changed = true
continue
}
if (isTerminalLeafId(leafId)) {
leafIdByInputLeafId.set(leafId, leafId)
continue
}
changed = true
const preferredLeafId = usePreferredLeafIds ? preferredLeafIdsInOrder[index] : undefined
leafIdByInputLeafId.set(
leafId,
preferredLeafId && isTerminalLeafId(preferredLeafId) ? preferredLeafId : randomUUID()
)
}
const root = changed
? cloneLayoutWithLeafIds(inputRoot, leafIdByInputLeafId, duplicatedInputLeafIds)
: inputRoot
const activeLeafId =
inputSnapshot.activeLeafId && !duplicatedInputLeafIds.has(inputSnapshot.activeLeafId)
? (leafIdByInputLeafId.get(inputSnapshot.activeLeafId) ?? firstLayoutLeafId(root))
: inputSnapshot.activeLeafId === null
? null
: firstLayoutLeafId(root)
const expandedLeafId =
inputSnapshot.expandedLeafId && !duplicatedInputLeafIds.has(inputSnapshot.expandedLeafId)
? (leafIdByInputLeafId.get(inputSnapshot.expandedLeafId) ?? null)
: null
const ptyIdsByLeafId = remapLeafRecordForPersistence(
inputSnapshot.ptyIdsByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const buffersByLeafId = remapLeafRecordForPersistence(
inputSnapshot.buffersByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const scrollbackRefsByLeafId = remapLeafRecordForPersistence(
inputSnapshot.scrollbackRefsByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const titlesByLeafId = remapLeafRecordForPersistence(
inputSnapshot.titlesByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const recordsChanged =
!leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) ||
!leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) ||
!leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) ||
!leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId)
const metadataChanged =
activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId
if (!changed && !recordsChanged && !metadataChanged) {
return { snapshot, changed: false, leafIdByInputLeafId }
}
const {
ptyIdsByLeafId: _oldPtyIdsByLeafId,
buffersByLeafId: _oldBuffersByLeafId,
scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId,
titlesByLeafId: _oldTitlesByLeafId,
...snapshotWithoutLeafRecords
} = inputSnapshot
return {
snapshot: {
...snapshotWithoutLeafRecords,
root,
activeLeafId,
expandedLeafId,
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}),
...(buffersByLeafId ? { buffersByLeafId } : {}),
...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}),
...(titlesByLeafId ? { titlesByLeafId } : {})
},
changed: true,
leafIdByInputLeafId
}
}
@@ -0,0 +1,221 @@
import type { LegacyPaneKeyAliasEntry, PersistedState } from '../../../shared/persisted-state-types'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types'
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { collectMigrationUnsupportedPtyEntries } from './pane-identity-migration'
import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization'
import {
legacyMigrationUnsupportedRowsToAliasEntries,
legacyPaneKeyAliasEntriesEqual,
mergeLegacyPaneKeyAliasEntries,
migrationUnsupportedEntriesEqual,
normalizeLegacyPaneKeyAliasEntries
} from './pane-alias-normalization'
export function normalizeWorkspaceSessionPaneIdentities(
session: WorkspaceSessionState,
priorLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
): {
session: WorkspaceSessionState
changed: boolean
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
} {
let changed = false
const leafIdByInputLeafIdByTabId = new Map<string, Map<string, string>>()
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const normalized = normalizeTerminalLayoutSnapshotForPersistence(
layout,
priorLayoutsByTabId[tabId]
)
terminalLayoutsByTabId[tabId] = normalized.snapshot
leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId)
const migrationEntries = collectMigrationUnsupportedPtyEntries({
session,
tabId,
inputLayout: layout,
normalizedLayout: normalized.snapshot,
leafIdByInputLeafId: normalized.leafIdByInputLeafId
})
// Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push().
for (const entry of migrationEntries.migrationUnsupportedEntries) {
migrationUnsupportedEntries.push(entry)
}
for (const entry of migrationEntries.legacyPaneKeyAliasEntries) {
legacyPaneKeyAliasEntries.push(entry)
}
const leafIdByPtyId = new Map<string, string>()
const duplicatePtyIds = new Set<string>()
for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) {
if (duplicatePtyIds.has(ptyId)) {
continue
}
if (leafIdByPtyId.has(ptyId)) {
leafIdByPtyId.delete(ptyId)
duplicatePtyIds.add(ptyId)
continue
}
leafIdByPtyId.set(ptyId, leafId)
}
leafIdByPtyIdByTabId.set(tabId, leafIdByPtyId)
changed ||= normalized.changed
}
return {
session: changed ? { ...session, terminalLayoutsByTabId } : session,
changed,
leafIdByInputLeafIdByTabId,
leafIdByPtyIdByTabId,
migrationUnsupportedEntries,
legacyPaneKeyAliasEntries
}
}
export function remapSshRemotePtyLeaseLeafIds(
leases: SshRemotePtyLease[],
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>,
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
): { leases: SshRemotePtyLease[]; changed: boolean } {
let changed = false
const nextLeases = leases.map((lease) => {
if (lease.leafId === undefined || isTerminalLeafId(lease.leafId)) {
return lease
}
const remappedLeafId = lease.tabId
? leafIdByInputLeafIdByTabId.get(lease.tabId)?.get(lease.leafId)
: undefined
const leafIdForPty = lease.tabId
? leafIdByPtyIdByTabId.get(lease.tabId)?.get(lease.ptyId)
: undefined
changed = true
const nextLeafId = remappedLeafId ?? leafIdForPty
if (nextLeafId) {
return { ...lease, leafId: nextLeafId }
}
const next = { ...lease }
// Why: unmatched legacy leaf ids are ambiguous after migration; don't re-persist them as durable pane identity.
delete next.leafId
return next
})
return { leases: nextLeases, changed }
}
export function normalizePersistedPaneIdentityState(state: PersistedState): {
state: PersistedState
changed: boolean
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
} {
const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {})
const remappedLeases = remapSshRemotePtyLeaseLeafIds(
state.sshRemotePtyLeases ?? [],
normalizedSession.leafIdByInputLeafIdByTabId,
normalizedSession.leafIdByPtyIdByTabId
)
const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([
...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries),
...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []),
...normalizedSession.legacyPaneKeyAliasEntries
])
const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys(
state.ui?.acknowledgedAgentsByPaneKey,
normalizedSession.leafIdByInputLeafIdByTabId
)
const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual(
state.migrationUnsupportedPtyEntries ?? [],
mergedMigrationUnsupportedEntries
)
const legacyAliasesChanged = !legacyPaneKeyAliasEntriesEqual(
state.legacyPaneKeyAliasEntries ?? [],
mergedLegacyPaneKeyAliasEntries
)
if (
!normalizedSession.changed &&
!remappedLeases.changed &&
!migrationUnsupportedChanged &&
!legacyAliasesChanged &&
!remappedAcknowledgements.changed
) {
return {
state,
changed: false,
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
}
}
return {
state: {
...state,
workspaceSession: normalizedSession.session,
sshRemotePtyLeases: remappedLeases.leases,
migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries,
...(remappedAcknowledgements.changed
? {
ui: {
...state.ui,
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
}
}
: {})
},
changed: true,
migrationUnsupportedEntries: mergedMigrationUnsupportedEntries,
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
}
}
export function remapAcknowledgedAgentPaneKeys(
acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
if (!acknowledgements || Object.keys(acknowledgements).length === 0) {
return { acknowledgements, changed: false }
}
let changed = false
const next: NonNullable<PersistedState['ui']['acknowledgedAgentsByPaneKey']> = {}
const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => {
const existing = next[paneKey]
next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt)
}
for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) {
const parsed = parsePaneKey(paneKey)
if (parsed) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
const delimiter = paneKey.indexOf(':')
if (delimiter <= 0 || delimiter === paneKey.length - 1) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
const tabId = paneKey.slice(0, delimiter)
const legacyLeafId = paneKey.slice(delimiter + 1)
const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
setAcknowledgement(paneKey, acknowledgedAt)
continue
}
try {
// Why: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread.
setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt)
changed = true
} catch {
setAcknowledgement(paneKey, acknowledgedAt)
}
}
return { acknowledgements: next, changed }
}
@@ -0,0 +1,232 @@
import type {
Automation,
AutomationPrecheckResult,
AutomationRun,
AutomationRunOutputSnapshot,
AutomationSchedulerOwner
} from '../../../shared/automations-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { ProjectHostSetup } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck'
import { getAutomationLegacyRepoId } from '../../../shared/automation-run-identity'
import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
} from '../../../shared/task-source-context'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
import { parsePaneKey } from '../../../shared/stable-pane-id'
export function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
export function normalizeAutomationRunTerminalPaneKey(
value: string | null | undefined
): string | null {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed && parsePaneKey(trimmed) ? trimmed : null
}
export function normalizeAutomationRunTerminalPtyId(
value: string | null | undefined
): string | null {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed || null
}
export function normalizeAutomationRunOutputSnapshot(
value: AutomationRunOutputSnapshot | null | undefined
): AutomationRunOutputSnapshot | null {
if (!value || value.format !== 'plain_text') {
return null
}
const content = typeof value.content === 'string' ? value.content : ''
if (!content.trim()) {
return null
}
return {
format: 'plain_text',
content,
capturedAt:
typeof value.capturedAt === 'number' && Number.isFinite(value.capturedAt)
? value.capturedAt
: Date.now(),
truncated: value.truncated === true
}
}
export function normalizeAutomationPrecheckResult(
value: AutomationPrecheckResult | null | undefined
): AutomationPrecheckResult | null {
if (!value || typeof value.command !== 'string' || !value.command.trim()) {
return null
}
const startedAt =
typeof value.startedAt === 'number' && Number.isFinite(value.startedAt)
? value.startedAt
: Date.now()
const completedAt =
typeof value.completedAt === 'number' && Number.isFinite(value.completedAt)
? value.completedAt
: startedAt
return {
command: value.command.trim(),
exitCode:
typeof value.exitCode === 'number' && Number.isFinite(value.exitCode) ? value.exitCode : null,
timedOut: value.timedOut === true,
durationMs:
typeof value.durationMs === 'number' && Number.isFinite(value.durationMs)
? Math.max(0, value.durationMs)
: Math.max(0, completedAt - startedAt),
stdout: typeof value.stdout === 'string' ? value.stdout : '',
stderr: typeof value.stderr === 'string' ? value.stderr : '',
stdoutTruncated: value.stdoutTruncated === true,
stderrTruncated: value.stderrTruncated === true,
error: typeof value.error === 'string' && value.error.trim() ? value.error : null,
startedAt,
completedAt
}
}
export function normalizeAutomationSessionReuse(automation: Automation): Automation {
const setupDecision = normalizeAutomationSetupDecisionForWorkspaceMode(
automation.workspaceMode,
automation.setupDecision
)
return {
...automation,
precheck: normalizeAutomationPrecheck(automation.precheck),
setupDecision,
reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true
}
}
export function normalizeAutomationSetupDecisionForWorkspaceMode(
workspaceMode: Automation['workspaceMode'],
setupDecision: unknown
): Automation['setupDecision'] {
return workspaceMode === 'new_per_run' && (setupDecision === 'run' || setupDecision === 'skip')
? setupDecision
: undefined
}
export function getAutomationContextsForRepo(
repo: Repo | undefined,
projectHostSetups: readonly ProjectHostSetup[]
): Pick<Automation, 'runContext' | 'sourceContext'> {
if (!repo) {
return {
runContext: null,
sourceContext: null
}
}
const projection = projectHostSetupProjectionFromRepos([repo])
const projectedProject = projection.projects[0]
const projectedSetup = projection.setups[0]
const setup =
projectHostSetups.find((candidate) => candidate.repoId === repo.id) ?? projectedSetup
const runContext = setup
? buildWorkspaceRunContext({
projectId: setup.projectId,
hostId: setup.hostId,
projectHostSetupId: setup.id,
repoId: repo.id,
path: setup.path
})
: null
const providerIdentity = projectedProject?.providerIdentity
const sourceContext = providerIdentity
? buildTaskSourceContextFromRepo({
provider: providerIdentity.provider,
projectId: providerIdentity.provider === 'github' ? (setup?.projectId ?? repo.id) : repo.id,
repo,
projectHostSetupId: setup?.id,
providerIdentity
})
: null
return {
runContext,
sourceContext
}
}
export function getAutomationSchedulerOwner(repo: Repo | undefined): AutomationSchedulerOwner {
if (!repo) {
return 'local_host_service'
}
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
if (host?.kind === 'ssh') {
return 'ssh_bridge'
}
if (host?.kind === 'runtime') {
return 'remote_host_service'
}
return 'local_host_service'
}
export function backfillLegacyAutomationContexts(
state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
): {
state: Pick<PersistedState, 'automations' | 'automationRuns' | 'repos' | 'projectHostSetups'>
changed: boolean
} {
let changed = false
const contextsByAutomationId = new Map<string, Pick<Automation, 'runContext' | 'sourceContext'>>()
const reposById = new Map((state.repos ?? []).map((repo) => [repo.id, repo]))
const automations = (state.automations ?? []).map((automation) => {
const contexts = getAutomationContextsForRepo(
reposById.get(getAutomationLegacyRepoId(automation)),
state.projectHostSetups ?? []
)
const next: Automation = { ...automation }
if (!Object.hasOwn(next, 'runContext')) {
// Why: pre-host-context automations only stored a repo id; backfill the run target once so dispatch/precheck stop inferring it.
next.runContext = contexts.runContext
changed = true
}
if (!Object.hasOwn(next, 'sourceContext')) {
next.sourceContext = contexts.sourceContext
changed = true
}
contextsByAutomationId.set(next.id, {
runContext: next.runContext ?? null,
sourceContext: next.sourceContext ?? null
})
return next
})
const automationRuns = (state.automationRuns ?? []).map((run) => {
const automationContexts = contextsByAutomationId.get(run.automationId)
const next: AutomationRun = { ...run }
if (!Object.hasOwn(next, 'runContext')) {
next.runContext = automationContexts?.runContext ?? null
changed = true
}
if (!Object.hasOwn(next, 'sourceContext')) {
next.sourceContext = automationContexts?.sourceContext ?? null
changed = true
}
if (!Object.hasOwn(next, 'terminalPaneKey')) {
next.terminalPaneKey = null
changed = true
}
if (!Object.hasOwn(next, 'terminalPtyId')) {
next.terminalPtyId = null
changed = true
}
return next
})
if (!changed) {
return { state, changed: false }
}
return {
state: {
...state,
automations,
automationRuns
},
changed: true
}
}
@@ -0,0 +1,158 @@
import { randomUUID } from 'node:crypto'
import type {
Automation,
AutomationCreateInput,
AutomationUpdateInput
} from '../../../shared/automations-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck'
import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import {
getAutomationContextsForRepo,
getAutomationSchedulerOwner,
normalizeAutomationSessionReuse,
normalizeAutomationSetupDecisionForWorkspaceMode
} from './automation-context-migration'
export type AutomationDefinitionOperations = {
state: StoreOwnedPersistedState
flush: () => void
recordCreated: () => void
}
export function listAutomations(state: PersistedState): Automation[] {
return (state.automations ?? [])
.map((automation) => normalizeAutomationSessionReuse(automation))
.sort((left, right) => left.name.localeCompare(right.name))
}
export function createAutomation(
operations: AutomationDefinitionOperations,
input: AutomationCreateInput
): Automation {
const repo = operations.state.repos.find((entry) => entry.id === input.projectId)
const now = Date.now()
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const schedulerOwner = getAutomationSchedulerOwner(repo)
const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? [])
const automation: Automation = {
id: randomUUID(),
name: input.name.trim() || 'Untitled automation',
prompt: input.prompt,
precheck: normalizeAutomationPrecheck(input.precheck),
agentId: input.agentId,
runContext: input.runContext ?? contexts.runContext,
sourceContext: input.sourceContext ?? contexts.sourceContext,
projectId: input.projectId,
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
schedulerOwner,
workspaceMode: input.workspaceMode,
workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null,
baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null,
setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode(
input.workspaceMode,
input.setupDecision
),
reuseSession: input.workspaceMode === 'existing' ? (input.reuseSession ?? false) : false,
timezone: input.timezone,
rrule: input.rrule,
dtstart: input.dtstart,
enabled: input.enabled ?? true,
nextRunAt: nextAutomationOccurrenceAfter(input.rrule, input.dtstart, now),
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: input.missedRunGraceMinutes ?? 720,
createdAt: now,
updatedAt: now
}
operations.state.automations = [...(operations.state.automations ?? []), automation]
operations.recordCreated()
operations.flush()
return automation
}
export function updateAutomation(
operations: AutomationDefinitionOperations,
id: string,
updates: AutomationUpdateInput
): Automation {
const index = (operations.state.automations ?? []).findIndex((entry) => entry.id === id)
if (index === -1) {
throw new Error('Automation not found.')
}
const current = operations.state.automations[index]
const repoId = updates.projectId ?? current.projectId
const repo = operations.state.repos.find((entry) => entry.id === repoId)
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const schedulerOwner = getAutomationSchedulerOwner(repo)
const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? [])
const rrule = updates.rrule ?? current.rrule
const dtstart = updates.dtstart ?? current.dtstart
const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined
const workspaceMode = updates.workspaceMode ?? current.workspaceMode
const updated: Automation = {
...current,
...updates,
name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name,
precheck: Object.hasOwn(updates, 'precheck')
? normalizeAutomationPrecheck(updates.precheck)
: normalizeAutomationPrecheck(current.precheck),
projectId: repoId,
runContext: Object.hasOwn(updates, 'runContext')
? (updates.runContext ?? null)
: updates.projectId !== undefined
? contexts.runContext
: (current.runContext ?? contexts.runContext),
sourceContext: Object.hasOwn(updates, 'sourceContext')
? (updates.sourceContext ?? null)
: updates.projectId !== undefined
? contexts.sourceContext
: (current.sourceContext ?? contexts.sourceContext),
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
schedulerOwner,
workspaceMode,
workspaceId:
workspaceMode === 'existing'
? Object.hasOwn(updates, 'workspaceId')
? (updates.workspaceId ?? null)
: current.workspaceId
: null,
baseBranch:
workspaceMode === 'new_per_run'
? Object.hasOwn(updates, 'baseBranch')
? (updates.baseBranch ?? null)
: (current.baseBranch ?? null)
: null,
setupDecision:
workspaceMode === 'new_per_run'
? Object.hasOwn(updates, 'setupDecision')
? normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, updates.setupDecision)
: normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, current.setupDecision)
: undefined,
reuseSession:
workspaceMode === 'existing'
? (updates.reuseSession ?? current.reuseSession ?? false)
: false,
rrule,
dtstart,
nextRunAt: scheduleChanged
? nextAutomationOccurrenceAfter(rrule, dtstart, Date.now())
: current.nextRunAt,
updatedAt: Date.now()
}
operations.state.automations[index] = updated
operations.flush()
return updated
}
export function deleteAutomation(operations: AutomationDefinitionOperations, id: string): void {
operations.state.automations = (operations.state.automations ?? []).filter(
(entry) => entry.id !== id
)
operations.state.automationRuns = (operations.state.automationRuns ?? []).filter(
(entry) => entry.automationId !== id
)
operations.flush()
}
@@ -0,0 +1,167 @@
import { randomUUID } from 'node:crypto'
import type {
Automation,
AutomationDispatchResult,
AutomationRun,
AutomationRunTrigger
} from '../../../shared/automations-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import {
nextAutomationRunNumber,
pruneAutomationRuns
} from '../../../shared/automation-run-retention'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import {
normalizeAutomationPrecheckResult,
normalizeAutomationRunOutputSnapshot,
normalizeAutomationRunTerminalPaneKey,
normalizeAutomationRunTerminalPtyId,
normalizeAutomationRunWorkspaceDisplayName
} from './automation-context-migration'
export type AutomationRunOperations = {
state: StoreOwnedPersistedState
flush: () => void
recordManualRun: () => void
getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null
}
export function listAutomationRuns(state: PersistedState, automationId?: string): AutomationRun[] {
const runs = state.automationRuns ?? []
return [...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)]
.map((run) => ({
...run,
precheckResult: normalizeAutomationPrecheckResult(run.precheckResult)
}))
.sort((left, right) => right.createdAt - left.createdAt)
}
export function createAutomationRun(
operations: AutomationRunOperations,
automation: Automation,
scheduledFor: number,
trigger: AutomationRunTrigger = 'scheduled'
): AutomationRun {
const existing = (operations.state.automationRuns ?? []).find(
(run) => run.automationId === automation.id && run.scheduledFor === scheduledFor
)
if (existing) {
return existing
}
const now = Date.now()
// Why: retention prunes old runs, so the retained count isn't the ordinal — carry the number forward from the newest survivor.
const runNumber = nextAutomationRunNumber(
(operations.state.automationRuns ?? []).filter((run) => run.automationId === automation.id)
)
const run: AutomationRun = {
id: randomUUID(),
automationId: automation.id,
runNumber,
runContext: automation.runContext ?? null,
sourceContext: automation.sourceContext ?? null,
title: `${automation.name} run ${runNumber}`,
scheduledFor,
status: 'pending',
trigger,
workspaceId: automation.workspaceId,
workspaceDisplayName: operations.getWorkspaceDisplayName(automation.workspaceId),
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
terminalPaneKey: null,
terminalPtyId: null,
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: null,
dispatchedAt: null,
createdAt: now
}
operations.state.automationRuns = pruneAutomationRuns([
...(operations.state.automationRuns ?? []),
run
])
if (trigger === 'manual') {
operations.recordManualRun()
}
operations.flush()
return run
}
export function updateAutomationRun(
operations: AutomationRunOperations,
result: AutomationDispatchResult
): AutomationRun {
const index = (operations.state.automationRuns ?? []).findIndex(
(entry) => entry.id === result.runId
)
if (index === -1) {
throw new Error('Automation run not found.')
}
const now = Date.now()
const current = operations.state.automationRuns[index]
const workspaceId = result.workspaceId ?? current.workspaceId
const workspaceDisplayName = Object.hasOwn(result, 'workspaceDisplayName')
? normalizeAutomationRunWorkspaceDisplayName(result.workspaceDisplayName ?? null)
: null
const updated: AutomationRun = {
...current,
status: result.status,
workspaceId,
workspaceDisplayName:
workspaceDisplayName ??
normalizeAutomationRunWorkspaceDisplayName(current.workspaceDisplayName ?? null) ??
operations.getWorkspaceDisplayName(workspaceId),
terminalSessionId: Object.hasOwn(result, 'terminalSessionId')
? (result.terminalSessionId ?? null)
: current.terminalSessionId,
terminalPaneKey: Object.hasOwn(result, 'terminalPaneKey')
? normalizeAutomationRunTerminalPaneKey(result.terminalPaneKey)
: normalizeAutomationRunTerminalPaneKey(current.terminalPaneKey),
terminalPtyId: Object.hasOwn(result, 'terminalPtyId')
? normalizeAutomationRunTerminalPtyId(result.terminalPtyId)
: normalizeAutomationRunTerminalPtyId(current.terminalPtyId),
outputSnapshot: Object.hasOwn(result, 'outputSnapshot')
? normalizeAutomationRunOutputSnapshot(result.outputSnapshot)
: normalizeAutomationRunOutputSnapshot(current.outputSnapshot),
precheckResult: Object.hasOwn(result, 'precheckResult')
? normalizeAutomationPrecheckResult(result.precheckResult)
: normalizeAutomationPrecheckResult(current.precheckResult),
usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null),
error: result.error ?? null,
startedAt: current.startedAt ?? now,
dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt
}
operations.state.automationRuns[index] = updated
const automation = operations.state.automations.find((entry) => entry.id === updated.automationId)
if (automation) {
automation.lastRunAt = now
automation.updatedAt = now
}
operations.flush()
return updated
}
export function snapshotAutomationRunWorkspaceDisplayName(
operations: AutomationRunOperations,
workspaceId: string,
displayName: string
): number {
const normalizedDisplayName = normalizeAutomationRunWorkspaceDisplayName(displayName)
if (!normalizedDisplayName) {
return 0
}
let updatedCount = 0
operations.state.automationRuns = (operations.state.automationRuns ?? []).map((run) => {
if (run.workspaceId !== workspaceId || run.workspaceDisplayName === normalizedDisplayName) {
return run
}
updatedCount += 1
return { ...run, workspaceDisplayName: normalizedDisplayName }
})
if (updatedCount > 0) {
operations.flush()
}
return updatedCount
}
@@ -0,0 +1,31 @@
import type { Automation } from '../../../shared/automations-types'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import {
latestAutomationOccurrenceAtOrBefore,
nextAutomationOccurrenceAfter
} from '../../../shared/automation-schedules'
export function advanceAutomationNextRun(
state: StoreOwnedPersistedState,
flush: () => void,
id: string,
now = Date.now()
): Automation {
const index = (state.automations ?? []).findIndex((entry) => entry.id === id)
if (index === -1) {
throw new Error('Automation not found.')
}
const current = state.automations[index]
const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now)
const updated = { ...current, nextRunAt, updatedAt: Date.now() }
state.automations[index] = updated
flush()
return updated
}
export function getLatestAutomationOccurrence(
automation: Automation,
now = Date.now()
): number | null {
return latestAutomationOccurrenceAtOrBefore(automation.rrule, automation.dtstart, now)
}
@@ -0,0 +1,123 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { ProjectGroup } from '../../../shared/project-group-types'
import {
createProjectGroup,
getProjectGroupSubtreeIds,
normalizeProjectGroupName
} from '../../../shared/project-groups'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal'
export type ProjectGroupMutationOperations = {
state: StoreOwnedPersistedState
scheduleSave: () => void
removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void
pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void
}
export class ProjectGroupPersistenceOperations {
constructor(private readonly operations: ProjectGroupMutationOperations) {}
private get state(): PersistedState {
return this.operations.state
}
private scheduleSave(): void {
this.operations.scheduleSave()
}
private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void {
this.operations.removeWorkspaceLineageForFolderParent(folderWorkspaceId)
}
private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void {
this.operations.pruneMobileClientTabSelections(matchesWorktreeId)
}
getProjectGroups(): ProjectGroup[] {
return [...(this.state.projectGroups ?? [])].sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
)
}
createProjectGroup(input: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroup['createdFrom']
}): ProjectGroup {
let maxOrder = -1
// Why: persisted group lists can be large enough to exceed spread limits.
for (const existingGroup of this.state.projectGroups ?? []) {
maxOrder = Math.max(maxOrder, existingGroup.tabOrder)
}
const group = createProjectGroup({
...input,
tabOrder: maxOrder + 1
})
this.state.projectGroups = [...(this.state.projectGroups ?? []), group]
this.scheduleSave()
return group
}
updateProjectGroup(
groupId: string,
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
): ProjectGroup | null {
const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId)
if (!group) {
return null
}
if (updates.name !== undefined) {
group.name = normalizeProjectGroupName(updates.name, group.name)
}
if (updates.isCollapsed !== undefined) {
group.isCollapsed = updates.isCollapsed
}
if (updates.tabOrder !== undefined && Number.isFinite(updates.tabOrder)) {
group.tabOrder = updates.tabOrder
}
if (updates.color !== undefined) {
group.color = typeof updates.color === 'string' ? updates.color : null
}
group.updatedAt = Date.now()
this.scheduleSave()
return group
}
deleteProjectGroup(groupId: string): boolean {
const before = this.state.projectGroups?.length ?? 0
const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId)
this.state.projectGroups = (this.state.projectGroups ?? []).filter(
(group) => !deletedGroupIds.has(group.id)
)
if ((this.state.projectGroups?.length ?? 0) === before) {
return false
}
// Why: groups are sidebar organization only, so deleting one ungroups its repos rather than deleting them.
this.state.repos = this.state.repos.map((repo) =>
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
const removedFolderWorkspaceKeys = new Set<string>()
for (const workspace of this.state.folderWorkspaces ?? []) {
if (deletedGroupIds.has(workspace.projectGroupId)) {
removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id))
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
folderWorkspaceKey(workspace.id)
)!
this.removeWorkspaceLineageForFolderParent(workspace.id)
}
}
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
)
this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId))
this.scheduleSave()
return true
}
}
@@ -0,0 +1,83 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { ProjectHostSetup } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection'
import { carryProjectStateThroughIdentityChange } from '../../../shared/project-identity-succession'
export function projectHostSetupCompatibilityStateEqual(
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
nextState: Pick<PersistedState, 'projects' | 'projectHostSetups'>
): boolean {
return (
JSON.stringify(state.projects ?? []) === JSON.stringify(nextState.projects) &&
JSON.stringify(state.projectHostSetups ?? []) === JSON.stringify(nextState.projectHostSetups)
)
}
export function isRepoBackedProjectHostSetup(
setup: ProjectHostSetup,
currentRepoIds: ReadonlySet<string>
): boolean {
const repoId = typeof setup.repoId === 'string' ? setup.repoId : ''
return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId)
}
export function mergeProjectHostSetupCompatibilityState(
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
repos: readonly Repo[]
): Pick<PersistedState, 'projects' | 'projectHostSetups'> {
const projection = projectHostSetupProjectionFromRepos(repos)
const succession = carryProjectStateThroughIdentityChange(
projection.projects,
state.projects ?? []
)
const currentRepoIds = new Set(repos.map((repo) => repo.id))
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
// Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts.
const independentSetups = (state.projectHostSetups ?? [])
.filter((setup) => {
if (projectedSetupIds.has(setup.id)) {
return false
}
return !isRepoBackedProjectHostSetup(setup, currentRepoIds)
})
// Why: follow the repo's project through a derived-id change so no ghost project row survives.
.map((setup) => {
const remappedProjectId = succession.remappedProjectIds.get(setup.projectId)
return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup
})
const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId))
const independentProjects = (state.projects ?? [])
.filter(
(project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id)
)
.map((project) => ({
...project,
sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId))
}))
return {
projects: [...succession.projects, ...independentProjects],
projectHostSetups: [...projection.setups, ...independentSetups]
}
}
export function makeProjectHostSetupId(
projectId: string,
hostId: ExecutionHostId,
existingIds: ReadonlySet<string>,
requestedId?: string
): string {
const baseId = requestedId?.trim() || `${projectId}::${hostId}`
if (!existingIds.has(baseId)) {
return baseId
}
let suffix = 2
let candidate = `${baseId}::${suffix}`
while (existingIds.has(candidate)) {
suffix++
candidate = `${baseId}::${suffix}`
}
return candidate
}
@@ -0,0 +1,231 @@
import type {
Project,
ProjectHostSetup,
ProjectHostSetupCreateArgs,
ProjectHostSetupCreateResult,
ProjectHostSetupDeleteArgs,
ProjectHostSetupDeleteResult,
ProjectHostSetupUpdateArgs,
ProjectHostSetupUpdateResult,
ProjectUpdateArgs
} from '../../../shared/project-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host'
import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { makeProjectHostSetupId } from './project-host-compatibility'
export type ProjectHostMutationOperations = {
state: StoreOwnedPersistedState
gitUsernameCache: Map<string, string>
hydrateRepo: (repo: Repo) => Repo
updateRepoBackedProjectHostSetup: (
setup: ProjectHostSetup,
repo: Repo,
updates: ProjectHostSetupUpdateArgs['updates']
) => { setup: ProjectHostSetup; repo: Repo } | null
updateIndependentProjectHostSetup: (
setup: ProjectHostSetup,
updates: ProjectHostSetupUpdateArgs['updates']
) => ProjectHostSetup
removeProjectForHost: (id: string, hostId: ProjectHostSetup['hostId']) => void
scheduleSave: () => void
}
export class ProjectHostPersistenceOperations {
constructor(private readonly operations: ProjectHostMutationOperations) {}
private get state(): PersistedState {
return this.operations.state
}
private get gitUsernameCache(): Map<string, string> {
return this.operations.gitUsernameCache
}
private hydrateRepo(repo: Repo): Repo {
return this.operations.hydrateRepo(repo)
}
private updateRepoBackedProjectHostSetup(
setup: ProjectHostSetup,
repo: Repo,
updates: ProjectHostSetupUpdateArgs['updates']
): { setup: ProjectHostSetup; repo: Repo } | null {
return this.operations.updateRepoBackedProjectHostSetup(setup, repo, updates)
}
private updateIndependentProjectHostSetup(
setup: ProjectHostSetup,
updates: ProjectHostSetupUpdateArgs['updates']
): ProjectHostSetup {
return this.operations.updateIndependentProjectHostSetup(setup, updates)
}
private removeProjectForHost(id: string, hostId: ProjectHostSetup['hostId']): void {
this.operations.removeProjectForHost(id, hostId)
}
private scheduleSave(): void {
this.operations.scheduleSave()
}
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
getProjects(): Project[] {
return [...this.state.projects]
}
updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null {
const project = this.state.projects.find((entry) => entry.id === id)
if (!project) {
return null
}
if ('localWindowsRuntimePreference' in updates) {
if (updates.localWindowsRuntimePreference === undefined) {
delete project.localWindowsRuntimePreference
} else {
project.localWindowsRuntimePreference = normalizeProjectRuntimePreference(
updates.localWindowsRuntimePreference
)
}
}
project.updatedAt = Date.now()
this.scheduleSave()
return { ...project }
}
getProjectHostSetups(): ProjectHostSetup[] {
return [...this.state.projectHostSetups]
}
createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null {
const project = this.state.projects.find((entry) => entry.id === args.projectId)
if (!project) {
return null
}
const hostId = normalizeExecutionHostId(args.hostId)
if (!hostId) {
throw new Error(`Invalid host ID: ${args.hostId}`)
}
const duplicateSetup = this.state.projectHostSetups.find(
(entry) => entry.projectId === project.id && entry.hostId === hostId
)
if (duplicateSetup) {
throw new Error(`Project host setup already exists: ${duplicateSetup.id}`)
}
const now = Date.now()
const existingIds = new Set(this.state.projectHostSetups.map((entry) => entry.id))
const setup: ProjectHostSetup = {
id: makeProjectHostSetupId(project.id, hostId, existingIds, args.setupId),
projectId: project.id,
hostId,
repoId: '',
path: args.path?.trim() ?? '',
displayName: args.displayName?.trim() || project.displayName,
...(args.kind ? { kind: args.kind } : {}),
...(args.worktreeBasePath?.trim() ? { worktreeBasePath: args.worktreeBasePath.trim() } : {}),
...(args.gitUsername?.trim() ? { gitUsername: args.gitUsername.trim() } : {}),
setupState: args.setupState ?? 'not-set-up',
setupMethod: args.setupMethod ?? 'provisioned',
createdAt: now,
updatedAt: now
}
// Why: persist independently so future repo projection sync doesn't erase this non-repo-backed setup.
this.state.projectHostSetups.push(setup)
this.scheduleSave()
return { project, setup }
}
updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null {
const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId)
if (!setup) {
return null
}
const project = this.state.projects.find((entry) => entry.id === setup.projectId)
if (!project) {
return null
}
const repo = setup.repoId
? this.state.repos.find((entry) => entry.id === setup.repoId)
: undefined
if (repo) {
const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates)
const updatedProject = updated
? this.state.projects.find((entry) => entry.id === updated.setup.projectId)
: undefined
return updated && updatedProject
? { project: updatedProject, setup: updated.setup, repo: updated.repo }
: null
}
const updatedSetup = this.updateIndependentProjectHostSetup(setup, args.updates)
return { project, setup: updatedSetup }
}
deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null {
const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId)
if (!setup) {
return null
}
const project = this.state.projects.find((entry) => entry.id === setup.projectId)
if (!project) {
return null
}
// Why: the same repo id can exist on multiple execution hosts, so match this setup's own host
// row and never fall back to a sibling host's row — a stale repoId/hostId would delete that host's
// registration. With no exact match the setup is stale, and the path below drops just the setup.
const repo = setup.repoId
? this.state.repos.find(
(entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId
)
: undefined
if (repo) {
this.removeProjectForHost(repo.id, setup.hostId)
return { project, setup, repo: this.hydrateRepo(repo) }
}
this.state.projectHostSetups = this.state.projectHostSetups.filter(
(entry) => entry.id !== setup.id
)
this.scheduleSave()
return { project, setup }
}
/** O(1) repo count; unlike `getRepos()` this skips per-repo hydration. */
getRepoCount(): number {
return this.state.repos.length
}
getRepo(id: string): Repo | undefined {
const repo = this.state.repos.find((r) => r.id === id)
return repo ? this.hydrateRepo(repo) : undefined
}
/**
* Record a background-resolved git username; kept out of updateRepo's whitelist so the renderer can't write it directly.
* @returns true when the hydrated value changed.
*/
setResolvedRepoGitUsername(id: string, username: string): boolean {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return false
}
const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? ''
this.gitUsernameCache.set(repo.path, username)
if (previous === username) {
return false
}
if (username) {
// Why: persist so the next launch hydrates repos with the right branch prefix before enrichment re-runs.
repo.gitUsername = username
} else {
delete repo.gitUsername
}
this.scheduleSave()
return true
}
}
@@ -0,0 +1,109 @@
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import type { RepoUpdatePersistenceOperations } from './repo-update-operations'
export type ProjectHostSetupUpdateOperations = {
state: StoreOwnedPersistedState
updateRepo: RepoUpdatePersistenceOperations['updateRepo']
scheduleSave: () => void
}
export class ProjectHostSetupPersistenceOperations {
constructor(private readonly operations: ProjectHostSetupUpdateOperations) {}
private get state(): PersistedState {
return this.operations.state
}
private updateRepo(
...args: Parameters<RepoUpdatePersistenceOperations['updateRepo']>
): ReturnType<RepoUpdatePersistenceOperations['updateRepo']> {
return this.operations.updateRepo(...args)
}
private scheduleSave(): void {
this.operations.scheduleSave()
}
updateRepoBackedProjectHostSetup(
setup: ProjectHostSetup,
repo: Repo,
updates: ProjectHostSetupUpdateArgs['updates']
): { setup: ProjectHostSetup; repo: Repo } | null {
if (updates.path !== undefined && updates.path !== repo.path) {
throw new Error(
'Repo-backed project host setup paths must be changed by re-importing the project.'
)
}
if (updates.setupState !== undefined && updates.setupState !== 'ready') {
throw new Error('Repo-backed project host setups cannot be marked unavailable.')
}
const repoUpdates: Parameters<RepoUpdatePersistenceOperations['updateRepo']>[1] = {}
if (updates.displayName !== undefined) {
repoUpdates.displayName = updates.displayName
}
if (updates.worktreeBasePath !== undefined) {
repoUpdates.worktreeBasePath = updates.worktreeBasePath
}
if (updates.kind !== undefined) {
repoUpdates.kind = updates.kind
}
if (updates.setupMethod === 'provisioned') {
throw new Error('Repo-backed project host setups cannot be marked provisioned.')
}
if (updates.setupMethod !== undefined && updates.setupMethod !== 'legacy-repo') {
repoUpdates.projectHostSetupMethod = updates.setupMethod
}
const updatedRepo =
Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo
if (!updatedRepo) {
return null
}
return {
setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup,
repo: updatedRepo
}
}
updateIndependentProjectHostSetup(
setup: ProjectHostSetup,
updates: ProjectHostSetupUpdateArgs['updates']
): ProjectHostSetup {
if (updates.displayName !== undefined) {
setup.displayName = updates.displayName.trim() || setup.displayName
}
if (updates.path !== undefined) {
setup.path = updates.path.trim() || setup.path
}
if (updates.worktreeBasePath !== undefined) {
const worktreeBasePath = updates.worktreeBasePath.trim()
if (worktreeBasePath) {
setup.worktreeBasePath = worktreeBasePath
} else {
delete setup.worktreeBasePath
}
}
if (updates.kind !== undefined) {
setup.kind = updates.kind
}
if (updates.gitUsername !== undefined) {
const gitUsername = updates.gitUsername.trim()
if (gitUsername) {
setup.gitUsername = gitUsername
} else {
delete setup.gitUsername
}
}
if (updates.setupState !== undefined) {
setup.setupState = updates.setupState
}
if (updates.setupMethod !== undefined) {
setup.setupMethod = updates.setupMethod
}
setup.updatedAt = Date.now()
this.scheduleSave()
return setup
}
}
@@ -0,0 +1,69 @@
import type { Repo } from '../../../shared/repo-types'
import { getDefaultRepoHookSettings } from '../../../shared/constants'
import { isFolderRepo } from '../../../shared/repo-kind'
import { sanitizeRepoIcon } from '../../../shared/repo-icon'
import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai'
import {
sanitizeForkSyncMode,
sanitizeGitRemoteIdentity,
sanitizeRepoProjectHostSetupMethod,
sanitizeRepoUpstream
} from './repo-sanitization'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../../shared/worktree/visibility-sources'
export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap<string, string>): Repo {
const {
repoIcon: rawRepoIcon,
upstream: rawUpstream,
gitRemoteIdentity: rawGitRemoteIdentity,
sourceControlAi: rawSourceControlAi,
projectHostSetupMethod: rawProjectHostSetupMethod,
forkSyncMode: rawForkSyncMode,
customWorktreeVisibilitySources: rawCustomWorktreeVisibilitySources,
worktreeVisibilitySourcePreferences: rawWorktreeVisibilitySourcePreferences,
...repoWithoutIcon
} = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
const upstream = sanitizeRepoUpstream(rawUpstream)
const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity)
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
const customWorktreeVisibilitySources = normalizeCustomWorktreeVisibilitySources(
rawCustomWorktreeVisibilitySources
)
const worktreeVisibilitySourcePreferences = normalizeWorktreeVisibilitySourcePreferences(
rawWorktreeVisibilitySourcePreferences
)
// Why: never spawn git/gh username resolution in hydration — a stuck probe froze Windows startup for minutes (issue #7225); read only cache/persisted value.
const gitUsername = isFolderRepo(repo)
? ''
: (gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '')
return {
...repoWithoutIcon,
...(repoIcon !== undefined ? { repoIcon } : {}),
...(upstream !== undefined ? { upstream } : {}),
...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}),
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
...(forkSyncMode !== undefined ? { forkSyncMode } : {}),
...(customWorktreeVisibilitySources !== undefined ? { customWorktreeVisibilitySources } : {}),
...(worktreeVisibilitySourcePreferences !== undefined
? { worktreeVisibilitySourcePreferences }
: {}),
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {
...getDefaultRepoHookSettings(),
...repo.hookSettings,
scripts: {
...getDefaultRepoHookSettings().scripts,
...repo.hookSettings?.scripts
}
}
}
}
@@ -0,0 +1,93 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
export type RepoOrderMutationOperations = {
state: StoreOwnedPersistedState
syncProjectHostSetupCompatibilityState: () => void
scheduleSave: () => void
}
export class RepoOrderPersistenceOperations {
constructor(private readonly operations: RepoOrderMutationOperations) {}
private get state(): PersistedState {
return this.operations.state
}
private syncProjectHostSetupCompatibilityState(): void {
this.operations.syncProjectHostSetupCompatibilityState()
}
private scheduleSave(): void {
this.operations.scheduleSave()
}
addRepo(repo: Repo): void {
this.state.repos.push(repo)
this.syncProjectHostSetupCompatibilityState()
this.scheduleSave()
}
// Why: return false on a stale permutation (concurrent add/remove) so the caller resyncs instead of persisting an order that drops/duplicates ids.
reorderRepos(orderedIds: string[]): boolean {
const current = this.state.repos
if (orderedIds.length !== current.length) {
return false
}
const seen = new Set<string>()
for (const id of orderedIds) {
if (typeof id !== 'string' || seen.has(id)) {
return false
}
seen.add(id)
}
const byId = new Map<string, Repo>()
for (const r of current) {
byId.set(r.id, r)
}
const next: Repo[] = []
for (const id of orderedIds) {
const repo = byId.get(id)
if (!repo) {
return false
}
next.push(repo)
}
this.state.repos = next
this.syncProjectHostSetupCompatibilityState()
this.scheduleSave()
return true
}
// Why: repo ids are unique only within an execution host; drags persist one permutation per host when local and SSH repos coexist.
reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean {
const current = this.state.repos
const hostRepos = current.filter((repo) => getRepoExecutionHostId(repo) === hostId)
if (orderedIds.length !== hostRepos.length) {
return false
}
const byId = new Map(hostRepos.map((repo) => [repo.id, repo]))
if (byId.size !== hostRepos.length) {
return false
}
const seen = new Set<string>()
const reorderedHostRepos: Repo[] = []
for (const id of orderedIds) {
const repo = typeof id === 'string' && !seen.has(id) ? byId.get(id) : undefined
if (!repo) {
return false
}
seen.add(id)
reorderedHostRepos.push(repo)
}
let nextHostIndex = 0
this.state.repos = current.map((repo) =>
getRepoExecutionHostId(repo) === hostId ? reorderedHostRepos[nextHostIndex++] : repo
)
this.syncProjectHostSetupCompatibilityState()
this.scheduleSave()
return true
}
}
@@ -0,0 +1,162 @@
import type { RepoProjectHostSetupMethod } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import type { GitRemoteIdentity } from '../../../shared/git-remote-identity'
import { normalizeRepoBadgeColor } from '../../../shared/repo-badge-color'
import { sanitizeRepoIcon } from '../../../shared/repo-icon'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../../shared/worktree/visibility-sources'
export function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
if (value === undefined) {
return undefined
}
if (value === null) {
return null
}
if (!value || typeof value !== 'object') {
return undefined
}
const candidate = value as { owner?: unknown; repo?: unknown; host?: unknown }
const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : ''
const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : ''
if (!owner || !repo) {
return undefined
}
// Why: an `upstream` remote may live on a different server than `origin`, so
// dropping the host forced consumers to re-infer it from origin and could bind
// a GHES parent to a same-named github.com repo. Absent host stays absent so
// records written before this survive unchanged.
const host = typeof candidate.host === 'string' ? candidate.host.trim() : ''
return host ? { owner, repo, host } : { owner, repo }
}
export function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined {
// Why: `null` is a resolved "no usable remote" marker; dropping it would make
// a settled repo indistinguishable from one whose identity probe is pending.
if (value === null) {
return null
}
if (!value || typeof value !== 'object') {
return undefined
}
const candidate = value as {
canonicalKey?: unknown
remoteName?: unknown
remoteUrl?: unknown
}
const canonicalKey =
typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : ''
const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : ''
const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : ''
return canonicalKey && remoteName && remoteUrl
? { canonicalKey, remoteName, remoteUrl }
: undefined
}
export function sanitizeRepoProjectHostSetupMethod(
value: unknown
): RepoProjectHostSetupMethod | undefined {
return value === 'imported-existing-folder' || value === 'cloned' ? value : undefined
}
export function sanitizeForkSyncMode(value: unknown): Repo['forkSyncMode'] | undefined {
return value === 'ask' || value === 'safe-auto' || value === 'off' ? value : undefined
}
export function sanitizeRepoUpdatesForPersistence<
T extends Partial<
Pick<
Repo,
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'gitRemoteIdentity'
| 'worktreeBasePath'
| 'projectHostSetupMethod'
| 'forkSyncMode'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
>
>
>(updates: T): T {
const sanitized = { ...updates }
if ('badgeColor' in sanitized) {
const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor)
if (!badgeColor) {
delete sanitized.badgeColor
} else {
sanitized.badgeColor = badgeColor
}
}
if ('repoIcon' in sanitized) {
const repoIcon = sanitizeRepoIcon(sanitized.repoIcon)
if (repoIcon === undefined) {
delete sanitized.repoIcon
} else {
sanitized.repoIcon = repoIcon
}
}
// Why: `null` is a valid "not a fork" / "no usable remote" marker; only drop malformed shapes.
if ('upstream' in sanitized) {
const upstream = sanitizeRepoUpstream(sanitized.upstream)
if (upstream === undefined) {
delete sanitized.upstream
} else {
sanitized.upstream = upstream
}
}
if ('gitRemoteIdentity' in sanitized) {
const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity)
if (gitRemoteIdentity === undefined) {
delete sanitized.gitRemoteIdentity
} else {
sanitized.gitRemoteIdentity = gitRemoteIdentity
}
}
if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) {
if (typeof sanitized.worktreeBasePath === 'string') {
sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined
} else {
delete sanitized.worktreeBasePath
}
}
if ('projectHostSetupMethod' in sanitized) {
const setupMethod = sanitizeRepoProjectHostSetupMethod(sanitized.projectHostSetupMethod)
if (setupMethod === undefined) {
delete sanitized.projectHostSetupMethod
} else {
sanitized.projectHostSetupMethod = setupMethod
}
}
if ('forkSyncMode' in sanitized) {
const forkSyncMode = sanitizeForkSyncMode(sanitized.forkSyncMode)
if (forkSyncMode === undefined) {
delete sanitized.forkSyncMode
} else {
sanitized.forkSyncMode = forkSyncMode
}
}
if ('customWorktreeVisibilitySources' in sanitized) {
const sources = normalizeCustomWorktreeVisibilitySources(
sanitized.customWorktreeVisibilitySources
)
if (!sources) {
delete sanitized.customWorktreeVisibilitySources
} else {
sanitized.customWorktreeVisibilitySources = sources
}
}
if ('worktreeVisibilitySourcePreferences' in sanitized) {
const preferences = normalizeWorktreeVisibilitySourcePreferences(
sanitized.worktreeVisibilitySourcePreferences
)
if (!preferences) {
delete sanitized.worktreeVisibilitySourcePreferences
} else {
sanitized.worktreeVisibilitySourcePreferences = preferences
}
}
return sanitized
}
@@ -0,0 +1,179 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { getRepoExecutionHostId } from '../../../shared/execution-host'
import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility'
import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization'
export type RepoUpdateMutationOperations = {
state: StoreOwnedPersistedState
syncProjectHostSetupCompatibilityState: () => void
scheduleSave: () => void
hydrateRepo: (repo: Repo) => Repo
}
export class RepoUpdatePersistenceOperations {
constructor(private readonly operations: RepoUpdateMutationOperations) {}
private get state(): PersistedState {
return this.operations.state
}
private syncProjectHostSetupCompatibilityState(): void {
this.operations.syncProjectHostSetupCompatibilityState()
}
private scheduleSave(): void {
this.operations.scheduleSave()
}
private hydrateRepo(repo: Repo): Repo {
return this.operations.hydrateRepo(repo)
}
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 {
const repo = this.state.repos.find(
(candidate) =>
candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId)
)
if (!repo) {
return null
}
const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates)
if (
'agentWorktreeVisibility' in sanitizedUpdates &&
!('worktreeVisibilitySourcePreferences' in sanitizedUpdates) &&
(sanitizedUpdates.agentWorktreeVisibility === 'hide' ||
sanitizedUpdates.agentWorktreeVisibility === 'show')
) {
sanitizedUpdates.worktreeVisibilitySourcePreferences = {
...repo.worktreeVisibilitySourcePreferences,
builtIn: {
claude: sanitizedUpdates.agentWorktreeVisibility,
gsd: sanitizedUpdates.agentWorktreeVisibility
}
}
}
if ('projectGroupId' in sanitizedUpdates) {
const nextGroupId = sanitizedUpdates.projectGroupId
if (
typeof nextGroupId !== 'string' ||
nextGroupId.trim().length === 0 ||
!this.state.projectGroups.some((group) => group.id === nextGroupId)
) {
sanitizedUpdates.projectGroupId = null
}
}
if (
'projectGroupOrder' in sanitizedUpdates &&
(typeof sanitizedUpdates.projectGroupOrder !== 'number' ||
!Number.isFinite(sanitizedUpdates.projectGroupOrder))
) {
delete sanitizedUpdates.projectGroupOrder
}
const externalWorktreeVisibilityLegacy =
'externalWorktreeVisibility' in sanitizedUpdates &&
repo.externalWorktreeVisibilityLegacy === undefined
? isLegacyRepoForExternalWorktreeVisibility(repo)
: undefined
// Why: selected repo fields use `undefined` as an explicit clear signal, so delete them before assigning the patch.
if (
'issueSourcePreference' in sanitizedUpdates &&
sanitizedUpdates.issueSourcePreference === undefined
) {
delete repo.issueSourcePreference
delete sanitizedUpdates.issueSourcePreference
}
if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) {
delete repo.worktreeBasePath
delete sanitizedUpdates.worktreeBasePath
}
if (
'externalWorktreeVisibility' in sanitizedUpdates &&
(sanitizedUpdates.externalWorktreeVisibility === undefined ||
sanitizedUpdates.externalWorktreeVisibility === null)
) {
delete repo.externalWorktreeVisibility
repo.externalWorktreeVisibilityLegacy = false
delete sanitizedUpdates.externalWorktreeVisibility
}
if (
'agentWorktreeVisibility' in sanitizedUpdates &&
sanitizedUpdates.agentWorktreeVisibility === null
) {
delete repo.agentWorktreeVisibility
delete sanitizedUpdates.agentWorktreeVisibility
}
if (
'externalWorktreeVisibility' in sanitizedUpdates &&
repo.externalWorktreeVisibilityLegacy === undefined
) {
// Why: old persisted repos have no marker; stamp it on first visibility change so later hide/show keeps legacy safety.
repo.externalWorktreeVisibilityLegacy = externalWorktreeVisibilityLegacy
}
if (
'externalWorktreeDiscoverySuppressedAt' in sanitizedUpdates &&
(sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === undefined ||
sanitizedUpdates.externalWorktreeDiscoverySuppressedAt === null)
) {
delete repo.externalWorktreeDiscoverySuppressedAt
delete sanitizedUpdates.externalWorktreeDiscoverySuppressedAt
}
if (
'sourceControlAi' in sanitizedUpdates &&
(sanitizedUpdates.sourceControlAi === undefined || sanitizedUpdates.sourceControlAi === null)
) {
delete repo.sourceControlAi
delete sanitizedUpdates.sourceControlAi
} else if ('sourceControlAi' in sanitizedUpdates) {
const normalizedSourceControlAi = normalizeRepoSourceControlAiOverrides(
sanitizedUpdates.sourceControlAi
)
if (normalizedSourceControlAi === undefined) {
delete sanitizedUpdates.sourceControlAi
} else {
sanitizedUpdates.sourceControlAi = normalizedSourceControlAi
}
}
Object.assign(repo, sanitizedUpdates)
this.syncProjectHostSetupCompatibilityState()
this.scheduleSave()
return this.hydrateRepo(repo)
}
}
@@ -0,0 +1,104 @@
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
export function pruneWorktreeStateForRepo(
state: StoreOwnedPersistedState,
id: string,
hostId: ExecutionHostId | null,
pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void
): void {
const prefix = `${id}::`
// Why snapshot up front: the first loop deletes metas, so reading meta.hostId live later would misclassify an SSH worktree as local.
const hostMembership = new Map<string, boolean>()
const belongsToHost = (key: string): boolean => {
if (!key.startsWith(prefix)) {
return false
}
if (hostId === null) {
return true
}
const cached = hostMembership.get(key)
if (cached !== undefined) {
return cached
}
// Why default to local: metas without hostId predate host stamping, so a host-scoped prune skips them rather than risk deleting another host's live meta.
const metaHostId = state.worktreeMeta[key]?.hostId ?? LOCAL_EXECUTION_HOST_ID
const result = metaHostId === hostId
hostMembership.set(key, result)
return result
}
// Why: session state (legacy blob + per-host partitions) references worktrees
// by the same `${repoId}::${path}` owner key; if it is not pruned here, a
// deleted project's worktrees stay in lastVisitedAtByWorktreeId /
// sleepingAgentSessionsByPaneKey and get re-materialized into worktreeMeta on
// the next launch, surfacing as an orphaned "unknown" workspace.
// worktreeMeta is host-classified via belongsToHost, but session partitions
// are keyed by host directly. A session owner key carries no host, and the
// same key can exist in multiple partitions (shared repo id/path across
// hosts). So for session cleanup we collect every prefix-matching owner key
// regardless of belongsToHost, and let the per-partition host gating below
// decide which partition to touch. (belongsToHost still governs
// worktreeMeta/lineage deletion. Collect before deleting worktreeMeta.)
const ownerKeysToPrune = new Set<string>()
const collectPrefixedKeys = (keys: Iterable<string>): void => {
for (const key of keys) {
if (key.startsWith(prefix)) {
ownerKeysToPrune.add(key)
}
}
}
collectPrefixedKeys(Object.keys(state.worktreeMeta))
collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {}))
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {}))
}
for (const key of Object.keys(state.worktreeMeta)) {
if (belongsToHost(key)) {
delete state.worktreeMeta[key]
}
}
// Why: owner keys are `${repoId}::${path}` and do not carry a host, so a
// host-scoped prune (hostId != null) must only touch that host's session:
// the legacy blob is the local host's session, and each
// workspaceSessionsByHostId partition is one non-local host. Pruning every
// partition here would wipe a surviving host's tabs, sleeping-agent state,
// and active-worktree pointer for a shared repo id/path. A full removal
// (hostId === null) still clears every host.
const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID
const pruneAllHostPartitions = hostId === null
if (pruneLegacyLocalSession) {
state.workspaceSession = removeWorkspaceSessionOwners(state.workspaceSession, ownerKeysToPrune)!
}
if (state.workspaceSessionsByHostId) {
for (const [partitionHostId, session] of Object.entries(state.workspaceSessionsByHostId)) {
if (!pruneAllHostPartitions && partitionHostId !== hostId) {
continue
}
const pruned = removeWorkspaceSessionOwners(session, ownerKeysToPrune)
if (pruned) {
state.workspaceSessionsByHostId[partitionHostId] = pruned
}
}
}
for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) {
if (belongsToHost(childId) || belongsToHost(lineage.parentWorktreeId)) {
delete state.worktreeLineageById[childId]
}
}
for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
const childScope = parseWorkspaceKey(childKey)
const parentScope = parseWorkspaceKey(lineage.parentWorkspaceKey)
if (childScope?.type === 'worktree' && belongsToHost(childScope.worktreeId)) {
delete state.workspaceLineageByChildKey[childKey as WorkspaceKey]
continue
}
if (parentScope?.type === 'worktree' && belongsToHost(parentScope.worktreeId)) {
delete state.workspaceLineageByChildKey[childKey as WorkspaceKey]
}
}
pruneMobileClientTabSelections(belongsToHost)
}
@@ -0,0 +1,205 @@
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
/**
* Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place;
* returns whether anything changed so the caller can gate its save. No-op when the ids match.
* See `Store.migrateWorktreeIdentity` for why the rename happens.
*/
export function migrateWorktreeIdentity(
state: StoreOwnedPersistedState,
oldWorktreeId: string,
newWorktreeId: string
): boolean {
if (oldWorktreeId === newWorktreeId) {
return false
}
const oldWorkspaceKey = worktreeWorkspaceKey(oldWorktreeId)
const newWorkspaceKey = worktreeWorkspaceKey(newWorktreeId)
const moveKey = <T>(
record: Record<string, T>,
mapValue: (value: T) => T = (value) => value
): boolean => {
if (!(oldWorktreeId in record)) {
return false
}
record[newWorktreeId] = mapValue(record[oldWorktreeId])
delete record[oldWorktreeId]
return true
}
const withNewWorktreeId = <T extends { worktreeId: string }>(value: T): T =>
value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value
const migrateSession = (session: WorkspaceSessionState | undefined): boolean => {
if (!session) {
return false
}
let sessionChanged = false
const moveSessionKey = <T>(
record: Record<string, T> | undefined,
mapValue: (value: T) => T = (value) => value
): boolean => {
if (!record) {
return false
}
let moved = false
const pairs: [string, string][] = [
[oldWorktreeId, newWorktreeId],
[oldWorkspaceKey, newWorkspaceKey]
]
for (const [oldKey, newKey] of pairs) {
if (!(oldKey in record)) {
continue
}
record[newKey] = mapValue(record[oldKey])
delete record[oldKey]
moved = true
}
return moved
}
sessionChanged =
moveSessionKey(session.tabsByWorktree, (tabs) => tabs.map(withNewWorktreeId)) ||
sessionChanged
sessionChanged =
moveSessionKey(session.openFilesByWorktree, (files) => files.map(withNewWorktreeId)) ||
sessionChanged
sessionChanged = moveSessionKey(session.activeFileIdByWorktree) || sessionChanged
sessionChanged =
moveSessionKey(session.browserTabsByWorktree, (workspaces) =>
workspaces.map(withNewWorktreeId)
) || sessionChanged
if (session.browserPagesByWorkspace) {
let pagesChanged = false
const nextPagesByWorkspace = { ...session.browserPagesByWorkspace }
for (const [workspaceId, pages] of Object.entries(nextPagesByWorkspace)) {
if (!pages.some((page) => page.worktreeId === oldWorktreeId)) {
continue
}
nextPagesByWorkspace[workspaceId] = pages.map(withNewWorktreeId)
pagesChanged = true
}
if (pagesChanged) {
session.browserPagesByWorkspace = nextPagesByWorkspace
sessionChanged = true
}
}
sessionChanged = moveSessionKey(session.activeBrowserTabIdByWorktree) || sessionChanged
sessionChanged = moveSessionKey(session.activeTabTypeByWorktree) || sessionChanged
sessionChanged = moveSessionKey(session.activeTabIdByWorktree) || sessionChanged
sessionChanged =
moveSessionKey(session.unifiedTabs, (tabs) => tabs.map(withNewWorktreeId)) || sessionChanged
sessionChanged =
moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged
sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged
sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged
sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged
sessionChanged =
moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged
if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) {
session.activeWorktreeIdsOnShutdown = session.activeWorktreeIdsOnShutdown.map((id) =>
id === oldWorktreeId ? newWorktreeId : id
)
sessionChanged = true
}
if (session.activeWorktreeId === oldWorktreeId) {
session.activeWorktreeId = newWorktreeId
sessionChanged = true
}
if (session.activeWorkspaceKey === oldWorkspaceKey) {
session.activeWorkspaceKey = newWorkspaceKey
sessionChanged = true
}
if (session.sleepingAgentSessionsByPaneKey) {
let sleepingChanged = false
const nextSleeping = { ...session.sleepingAgentSessionsByPaneKey }
for (const [paneKey, record] of Object.entries(nextSleeping)) {
if (record.worktreeId !== oldWorktreeId) {
continue
}
nextSleeping[paneKey] = { ...record, worktreeId: newWorktreeId }
sleepingChanged = true
}
if (sleepingChanged) {
session.sleepingAgentSessionsByPaneKey = nextSleeping
sessionChanged = true
}
}
if (session.terminalSurfaceTombstonesByPaneKey) {
let tombstonesChanged = false
const nextTombstones = { ...session.terminalSurfaceTombstonesByPaneKey }
for (const [paneKey, tombstone] of Object.entries(nextTombstones)) {
if (tombstone.worktreeId !== oldWorktreeId) {
continue
}
nextTombstones[paneKey] = { ...tombstone, worktreeId: newWorktreeId }
tombstonesChanged = true
}
if (tombstonesChanged) {
session.terminalSurfaceTombstonesByPaneKey = nextTombstones
sessionChanged = true
}
}
return sessionChanged
}
let changed = moveKey(state.worktreeMeta)
// Record the prior id so a session minted under it isn't reaped as an orphan.
const newMeta = state.worktreeMeta[newWorktreeId]
if (newMeta) {
const prior = newMeta.priorWorktreeIds ?? []
if (!prior.includes(oldWorktreeId)) {
newMeta.priorWorktreeIds = [...prior, oldWorktreeId]
changed = true
}
}
changed = moveKey(state.worktreeLineageById) || changed
const movedLineage = state.worktreeLineageById[newWorktreeId]
if (movedLineage && movedLineage.worktreeId === oldWorktreeId) {
movedLineage.worktreeId = newWorktreeId
}
// Why: children carry this as parentWorktreeId; keep the denormalized path-derived id consistent (parentWorktreeInstanceId is stable).
for (const lineage of Object.values(state.worktreeLineageById)) {
if (lineage.parentWorktreeId === oldWorktreeId) {
lineage.parentWorktreeId = newWorktreeId
changed = true
}
}
if (oldWorkspaceKey in state.workspaceLineageByChildKey) {
const lineage = state.workspaceLineageByChildKey[oldWorkspaceKey]
state.workspaceLineageByChildKey[newWorkspaceKey] = {
...lineage,
childWorkspaceKey: newWorkspaceKey
}
delete state.workspaceLineageByChildKey[oldWorkspaceKey]
changed = true
}
for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
if (lineage.parentWorkspaceKey === oldWorkspaceKey) {
state.workspaceLineageByChildKey[childKey as WorkspaceKey] = {
...lineage,
parentWorkspaceKey: newWorkspaceKey
}
changed = true
}
}
changed = migrateSession(state.workspaceSession) || changed
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
changed = migrateSession(session) || changed
}
for (const selectionsByWorktree of Object.values(
state.mobileClientTabSelectionsByDeviceId ?? {}
)) {
changed = moveKey(selectionsByWorktree) || changed
}
const showDotfiles = state.ui?.showDotfilesByWorktree
if (showDotfiles) {
changed = moveKey(showDotfiles) || changed
}
return changed
}
@@ -0,0 +1,121 @@
import { existsSync } from 'node:fs'
import { isAbsolute } from 'node:path'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { isWslUncPath } from '../../../shared/wsl-paths'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
areTaskSourceContextsEqual,
normalizeStoredTaskSourceContext
} from '../../../shared/task-source-context'
import {
areWorkspaceLinkedItemsEqual,
normalizeWorkspaceLinkedItem
} from '../../../shared/workspace-linked-item'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
// Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install).
// GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace.
export const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000
export const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000
export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number {
// Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing.
state.worktreeMeta ??= {}
const repoById = new Map(state.repos.map((repo) => [repo.id, repo]))
const projectIds = new Set((state.projects ?? []).map((project) => project.id))
const now = Date.now()
let removed = 0
for (const key of Object.keys(state.worktreeMeta)) {
// Why: folder-project workspace instances (keyed repoId::path::workspace:<uuid>) ARE the workspace record, not a checkout row; skip them.
if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) {
continue
}
const separator = key.indexOf('::')
if (separator === -1) {
continue
}
const ownerId = key.slice(0, separator)
const worktreePath = key.slice(separator + 2)
const meta = state.worktreeMeta[key]
const repo = repoById.get(ownerId)
if (repo) {
if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
continue
}
} else if (projectIds.has(ownerId)) {
// Project-owned metas keep their own project/host lifecycle; leave them alone.
continue
}
// Unowned entries (repo removed before metas were pruned) fall through to the same missing-path + idle-grace gate.
if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) {
continue
}
if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) {
continue
}
// Why: WSL worktrees on Windows carry Linux-style paths that Windows existsSync can't probe and would falsely condemn.
if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) {
continue
}
// Why keep timestamp-less entries: without timestamps we can't prove the 30-day grace elapsed (measured dead entries all had them).
// Grace is checked before existsSync so active entries skip the stat fan-out (and its slow-NFS tail).
const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0)
if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) {
continue
}
if (existsSync(worktreePath)) {
continue
}
delete state.worktreeMeta[key]
delete state.worktreeLineageById[key]
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
removed++
}
return removed
}
export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean {
let changed = false
const rawWorktreeMeta = state.worktreeMeta as unknown
if (
typeof rawWorktreeMeta !== 'object' ||
rawWorktreeMeta === null ||
Array.isArray(rawWorktreeMeta)
) {
state.worktreeMeta = {}
changed = rawWorktreeMeta !== undefined
}
for (const [key, meta] of Object.entries(state.worktreeMeta)) {
// Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta
// keeps timestamp-less keys forever and every downstream consumer trusts the Record<string, WorktreeMeta> type.
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
delete state.worktreeMeta[key]
// Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would
// otherwise re-attach to a worktree recreated at the same repoId::path.
delete state.worktreeLineageById[key]
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
changed = true
continue
}
const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem)
const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext)
const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch(
linkedWorkItem,
sourceContext
)
? sourceContext
: null
if (!areWorkspaceLinkedItemsEqual(meta.linkedWorkItem, linkedWorkItem)) {
meta.linkedWorkItem = linkedWorkItem
changed = true
}
if (!areTaskSourceContextsEqual(meta.linkedTaskSourceContext, linkedTaskSourceContext)) {
meta.linkedTaskSourceContext = linkedTaskSourceContext
changed = true
}
}
return changed
}
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
// Import from the production source of truth so a filename rename can't silently
@@ -160,6 +161,46 @@ describe('mobile pairing userData path stability', () => {
expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(canonicalKeypair)
})
it('rolls back the first copy when the second file fails to migrate', async () => {
// A half-copied pair would leave the registry without its E2EE key and, worse,
// trip the existing-target guard so the next launch never retries.
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
let copies = 0
return {
...actual,
default: actual,
copyFileSync: (source: string, target: string) => {
copies += 1
if (copies === 2) {
throw new Error('simulated copy failure')
}
actual.copyFileSync(source, target)
}
}
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
appState.userData = canonicalDir
const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } =
await import('../persistence')
initDataPath()
appState.userData = lateDir
writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), JSON.stringify([]))
writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), JSON.stringify({ v: 1 }))
expect(() => migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)).not.toThrow()
expect(errorSpy).toHaveBeenCalled()
expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false)
expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(false)
} finally {
errorSpy.mockRestore()
vi.doUnmock('node:fs')
}
})
it('no-ops when the source path equals the canonical path (no rename happened)', async () => {
// Case-insensitive filesystems (macOS/Windows) resolve both paths to the same
// dir, so migration must be a clean no-op rather than copy a file onto itself.