mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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
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.
|
||||
|
||||
Reference in New Issue
Block a user