mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(persistence): address review feedback on extracted modules (#14862)
Follow-ups to the module extraction (#14252): - Guard the lineage companion maps in worktree meta GC, and clear them alongside a corrupt worktreeMeta so stale rows can't re-attach. - Repair a renamed worktree's stale lineage.worktreeId and flag the save. - Key the git-username cache by execution host + path so the same checkout path on local/SSH/runtime hosts can't cross-hydrate usernames. - Drop undefined keys before the automation update spread; a Partial with an explicit undefined blanked stored values. - Normalize pane identities in every workspaceSessionsByHostId partition, not just the legacy blob, and use the merged leaf maps for lease and acknowledgement remapping. - Reject duplicate preferred leaf ids so two panes can't collapse onto one pty/buffer/scrollback key. - Let an explicit rightSidebarExplorerView outrank the legacy search-tab fallback; the legacy migration now runs on the raw payload at load. - Record why the mobile pairing migration leaves its sources in place. - Trim persisted folderPath, normalize synthesized worktree visibility preferences, validate notification settings field-by-field, strip undefined optional keys before the SSH lease merge, reuse `now` for an automation's updatedAt, and rename the pane alias registrar.
This commit is contained in:
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { WorkspaceSessionState } from '../shared/workspace-session-state-types'
|
||||
import { getDefaultWorkspaceSession } from '../shared/constants'
|
||||
import { isTerminalLeafId } from '../shared/stable-pane-id'
|
||||
import {
|
||||
testState,
|
||||
createStore,
|
||||
@@ -165,6 +166,63 @@ describe('Store host-partitioned workspace sessions', () => {
|
||||
expect(store.getWorkspaceSession('local').activeRepoId).toBe('canonical-local')
|
||||
})
|
||||
|
||||
it('rewrites legacy pane ids inside a host partition and remaps its leases', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
workspaceSession: makeHostSession('local-repo'),
|
||||
workspaceSessionsByHostId: {
|
||||
'ssh:host-b': {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'repo-ssh',
|
||||
activeWorktreeId: 'repo-ssh::/worktree',
|
||||
activeTabId: 'tab-ssh',
|
||||
tabsByWorktree: {
|
||||
'repo-ssh::/worktree': [
|
||||
{
|
||||
id: 'tab-ssh',
|
||||
worktreeId: 'repo-ssh::/worktree',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'remote-pty'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-ssh': {
|
||||
root: { type: 'leaf', leafId: 'pane:1' },
|
||||
activeLeafId: 'pane:1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'pane:1': 'remote-pty' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
sshRemotePtyLeases: [
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
ptyId: 'remote-pty',
|
||||
worktreeId: 'repo-ssh::/worktree',
|
||||
tabId: 'tab-ssh',
|
||||
leafId: 'pane:1',
|
||||
state: 'detached',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
const root = store.getWorkspaceSession('ssh:host-b').terminalLayoutsByTabId['tab-ssh']?.root
|
||||
const leafId = root?.type === 'leaf' ? root.leafId : null
|
||||
expect(leafId && isTerminalLeafId(leafId)).toBe(true)
|
||||
// The lease follows the partition's rewritten leaf, not the legacy `pane:1`.
|
||||
expect(store.getSshRemotePtyLeases('ssh-1')[0]?.leafId).toBe(leafId)
|
||||
})
|
||||
|
||||
it('isolates writes: setting host A does not mutate host B or local', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
|
||||
@@ -42,10 +42,20 @@ export function normalizeNotificationSettings(value: unknown): NotificationSetti
|
||||
typeof rawVolume === 'number' && Number.isFinite(rawVolume)
|
||||
? Math.min(100, Math.max(0, rawVolume))
|
||||
: defaults.customSoundVolume
|
||||
// Why field-by-field: a blanket spread let a type-flipped value on disk through, so `enabled: "false"`
|
||||
// stayed truthy and `customSoundPath: 42` reached the sound loader.
|
||||
const booleanOr = (raw: unknown, fallback: boolean): boolean =>
|
||||
typeof raw === 'boolean' ? raw : fallback
|
||||
return {
|
||||
...defaults,
|
||||
...candidate,
|
||||
enabled: booleanOr(candidate.enabled, defaults.enabled),
|
||||
agentTaskComplete: booleanOr(candidate.agentTaskComplete, defaults.agentTaskComplete),
|
||||
terminalBell: booleanOr(candidate.terminalBell, defaults.terminalBell),
|
||||
suppressWhenFocused: booleanOr(candidate.suppressWhenFocused, defaults.suppressWhenFocused),
|
||||
customSoundId,
|
||||
customSoundPath:
|
||||
typeof candidate.customSoundPath === 'string'
|
||||
? candidate.customSoundPath
|
||||
: defaults.customSoundPath,
|
||||
customSoundVolume
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +84,13 @@ 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
|
||||
}
|
||||
// Why: older builds persisted Search as a standalone activity tab with no explorer view; 'search'
|
||||
// is still a live tab, so this fallback must not outrank an explicit view.
|
||||
if (tab === 'search') {
|
||||
return 'search'
|
||||
}
|
||||
return getDefaultUIState().rightSidebarExplorerView
|
||||
}
|
||||
|
||||
@@ -38,9 +38,14 @@ export function upsertSshRemotePtyLease(
|
||||
)
|
||||
const existing =
|
||||
existingIndex !== -1 ? operations.state.sshRemotePtyLeases[existingIndex] : undefined
|
||||
// Why: callers pass optional fields as explicit `undefined`, which would blank the stored tabId/leafId
|
||||
// (and friends) when re-upserting an existing lease.
|
||||
const definedLease = Object.fromEntries(
|
||||
Object.entries(normalizedLease).filter(([, value]) => value !== undefined)
|
||||
) as typeof normalizedLease
|
||||
const next: SshRemotePtyLease = {
|
||||
...existing,
|
||||
...normalizedLease,
|
||||
...definedLease,
|
||||
createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now,
|
||||
updatedAt: normalizedLease.updatedAt ?? now
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ import {
|
||||
stripRetiredGlobalSettings
|
||||
} from '../applying-settings/terminal-settings-migrations'
|
||||
import {
|
||||
normalizeRightSidebarExplorerView,
|
||||
normalizeRightSidebarTab,
|
||||
normalizeShowDotfilesByWorktree,
|
||||
normalizeSortBy
|
||||
@@ -1410,6 +1411,12 @@ export class Store {
|
||||
// Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet.
|
||||
rightSidebarOpen,
|
||||
rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab),
|
||||
// Why here and not in getPersistedUI: only the raw payload still shows the legacy
|
||||
// "Search tab, no explorer view" shape — the defaults spread above fills in 'files'.
|
||||
rightSidebarExplorerView: normalizeRightSidebarExplorerView(
|
||||
parsed.ui?.rightSidebarExplorerView,
|
||||
parsed.ui?.rightSidebarTab
|
||||
),
|
||||
setupGuideSidebarDismissed,
|
||||
usagePercentageDisplayChangeNoticeDismissed,
|
||||
setupGuideBrowserMilestoneMigrated:
|
||||
|
||||
@@ -67,6 +67,10 @@ export function getCanonicalUserDataPath(): string {
|
||||
* 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.
|
||||
*
|
||||
* Sources are deliberately left in place: a copy-then-delete has no atomic form across the userData
|
||||
* dirs, and losing the originals to a crash mid-migration would strand every paired device. They stay
|
||||
* readable by an older build the user rolls back to; removing them is a separate cleanup decision.
|
||||
*/
|
||||
export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void {
|
||||
const targetUserDataDir = getCanonicalUserDataPath()
|
||||
|
||||
@@ -66,9 +66,10 @@ export class FolderWorkspacePersistenceOperations {
|
||||
const group = (this.state.projectGroups ?? []).find(
|
||||
(entry) => entry.id === input.projectGroupId
|
||||
)
|
||||
// Why trim: the guard below accepts a padded path, so persist the same value it validated.
|
||||
const folderPath =
|
||||
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
|
||||
? input.folderPath
|
||||
? input.folderPath.trim()
|
||||
: group?.parentPath
|
||||
if (!group || !folderPath) {
|
||||
throw new Error('Folder-backed project group not found.')
|
||||
@@ -137,7 +138,7 @@ export class FolderWorkspacePersistenceOperations {
|
||||
workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name)
|
||||
}
|
||||
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
|
||||
workspace.folderPath = updates.folderPath
|
||||
workspace.folderPath = updates.folderPath.trim()
|
||||
}
|
||||
if (updates.linkedTask !== undefined) {
|
||||
workspace.linkedTask = normalizeWorkspaceLinkedItem(updates.linkedTask)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -18,18 +17,14 @@ export function findWorktreeIdForTab(
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type PaneIdentityMigrationEntries = {
|
||||
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
|
||||
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
|
||||
}
|
||||
|
||||
export function collectMigrationUnsupportedPtyEntries(args: {
|
||||
/** Bridges a tab's legacy numeric pane keys to stable ones; returns the alias rows worth persisting. */
|
||||
export function registerLegacyPaneKeyAliasesForTab(args: {
|
||||
session: WorkspaceSessionState
|
||||
tabId: string
|
||||
inputLayout: TerminalLayoutSnapshot
|
||||
normalizedLayout: TerminalLayoutSnapshot
|
||||
leafIdByInputLeafId: Map<string, string>
|
||||
}): PaneIdentityMigrationEntries {
|
||||
}): LegacyPaneKeyAliasEntry[] {
|
||||
const worktreeId = findWorktreeIdForTab(args.session, args.tabId)
|
||||
const tab = worktreeId
|
||||
? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId)
|
||||
@@ -111,6 +106,5 @@ export function collectMigrationUnsupportedPtyEntries(args: {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: legacy numeric pane keys are now bridged by aliases, not persisted as restart-required rows.
|
||||
return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries }
|
||||
return legacyPaneKeyAliasEntries
|
||||
}
|
||||
|
||||
@@ -174,8 +174,13 @@ export function normalizeTerminalLayoutSnapshotForPersistence(
|
||||
)
|
||||
const inputLeafIdsInOrder = collectLayoutLeafIdsInOrder(inputRoot)
|
||||
const preferredLeafIdsInOrder = collectLayoutLeafIdsInOrder(preferredLayout?.root)
|
||||
const usePreferredLeafIds = preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length
|
||||
// Why the uniqueness check: a preferred layout that repeats a UUID would hand two input leaves the
|
||||
// same id, collapsing their pty/buffer/scrollback/title records onto one key.
|
||||
const usePreferredLeafIds =
|
||||
preferredLeafIdsInOrder.length === inputLeafIdsInOrder.length &&
|
||||
new Set(preferredLeafIdsInOrder).size === preferredLeafIdsInOrder.length
|
||||
const leafIdByInputLeafId = new Map<string, string>()
|
||||
const claimedLeafIds = new Set<string>()
|
||||
for (const [index, leafId] of inputLeafIdsInOrder.entries()) {
|
||||
const count = counts.get(leafId) ?? 0
|
||||
if (count !== 1 || leafIdByInputLeafId.has(leafId)) {
|
||||
@@ -184,14 +189,17 @@ export function normalizeTerminalLayoutSnapshotForPersistence(
|
||||
}
|
||||
if (isTerminalLeafId(leafId)) {
|
||||
leafIdByInputLeafId.set(leafId, leafId)
|
||||
claimedLeafIds.add(leafId)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
const preferredLeafId = usePreferredLeafIds ? preferredLeafIdsInOrder[index] : undefined
|
||||
leafIdByInputLeafId.set(
|
||||
leafId,
|
||||
preferredLeafId && isTerminalLeafId(preferredLeafId) ? preferredLeafId : randomUUID()
|
||||
)
|
||||
const nextLeafId =
|
||||
preferredLeafId && isTerminalLeafId(preferredLeafId) && !claimedLeafIds.has(preferredLeafId)
|
||||
? preferredLeafId
|
||||
: randomUUID()
|
||||
claimedLeafIds.add(nextLeafId)
|
||||
leafIdByInputLeafId.set(leafId, nextLeafId)
|
||||
}
|
||||
const root = changed
|
||||
? cloneLayoutWithLeafIds(inputRoot, leafIdByInputLeafId, duplicatedInputLeafIds)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
|
||||
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 { registerLegacyPaneKeyAliasesForTab } from './pane-identity-migration'
|
||||
import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization'
|
||||
import {
|
||||
legacyMigrationUnsupportedRowsToAliasEntries,
|
||||
@@ -28,6 +28,8 @@ export function normalizeWorkspaceSessionPaneIdentities(
|
||||
let changed = false
|
||||
const leafIdByInputLeafIdByTabId = new Map<string, Map<string, string>>()
|
||||
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
|
||||
// Why always empty: legacy numeric pane keys are bridged by aliases now, not persisted as
|
||||
// restart-required rows; the field stays so callers keep clearing stale rows written by old builds.
|
||||
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
|
||||
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
|
||||
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
|
||||
@@ -38,7 +40,7 @@ export function normalizeWorkspaceSessionPaneIdentities(
|
||||
)
|
||||
terminalLayoutsByTabId[tabId] = normalized.snapshot
|
||||
leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId)
|
||||
const migrationEntries = collectMigrationUnsupportedPtyEntries({
|
||||
const tabAliasEntries = registerLegacyPaneKeyAliasesForTab({
|
||||
session,
|
||||
tabId,
|
||||
inputLayout: layout,
|
||||
@@ -46,10 +48,7 @@ export function normalizeWorkspaceSessionPaneIdentities(
|
||||
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) {
|
||||
for (const entry of tabAliasEntries) {
|
||||
legacyPaneKeyAliasEntries.push(entry)
|
||||
}
|
||||
const leafIdByPtyId = new Map<string, string>()
|
||||
@@ -107,6 +106,19 @@ export function remapSshRemotePtyLeaseLeafIds(
|
||||
return { leases: nextLeases, changed }
|
||||
}
|
||||
|
||||
/** Combines per-tab leaf maps from separate host partitions; an already-mapped tab keeps its mapping. */
|
||||
function mergeLeafIdMapsByTabId(
|
||||
target: Map<string, Map<string, string>>,
|
||||
source: Map<string, Map<string, string>>
|
||||
): Map<string, Map<string, string>> {
|
||||
const merged = new Map(target)
|
||||
for (const [tabId, leafIds] of source) {
|
||||
const existing = merged.get(tabId)
|
||||
merged.set(tabId, existing ? new Map([...leafIds, ...existing]) : new Map(leafIds))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
state: PersistedState
|
||||
changed: boolean
|
||||
@@ -114,20 +126,54 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
|
||||
} {
|
||||
const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {})
|
||||
let leafIdByInputLeafIdByTabId = normalizedSession.leafIdByInputLeafIdByTabId
|
||||
let leafIdByPtyIdByTabId = normalizedSession.leafIdByPtyIdByTabId
|
||||
const hostSessionLegacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
|
||||
// Why: SSH/runtime hosts keep their own session blob, and their legacy leaves need the same UUID
|
||||
// rewrite — otherwise their leases and read markers still point at `pane:1` after migration.
|
||||
const normalizedHostSessions = state.workspaceSessionsByHostId
|
||||
? { ...state.workspaceSessionsByHostId }
|
||||
: undefined
|
||||
let hostSessionsChanged = false
|
||||
if (normalizedHostSessions) {
|
||||
for (const hostId of Object.keys(
|
||||
normalizedHostSessions
|
||||
) as (keyof typeof normalizedHostSessions)[]) {
|
||||
const hostSession = normalizedHostSessions[hostId]
|
||||
if (!hostSession) {
|
||||
continue
|
||||
}
|
||||
const normalizedHostSession = normalizeWorkspaceSessionPaneIdentities(hostSession, {})
|
||||
normalizedHostSessions[hostId] = normalizedHostSession.session
|
||||
leafIdByInputLeafIdByTabId = mergeLeafIdMapsByTabId(
|
||||
leafIdByInputLeafIdByTabId,
|
||||
normalizedHostSession.leafIdByInputLeafIdByTabId
|
||||
)
|
||||
leafIdByPtyIdByTabId = mergeLeafIdMapsByTabId(
|
||||
leafIdByPtyIdByTabId,
|
||||
normalizedHostSession.leafIdByPtyIdByTabId
|
||||
)
|
||||
for (const entry of normalizedHostSession.legacyPaneKeyAliasEntries) {
|
||||
hostSessionLegacyPaneKeyAliasEntries.push(entry)
|
||||
}
|
||||
hostSessionsChanged ||= normalizedHostSession.changed
|
||||
}
|
||||
}
|
||||
const remappedLeases = remapSshRemotePtyLeaseLeafIds(
|
||||
state.sshRemotePtyLeases ?? [],
|
||||
normalizedSession.leafIdByInputLeafIdByTabId,
|
||||
normalizedSession.leafIdByPtyIdByTabId
|
||||
leafIdByInputLeafIdByTabId,
|
||||
leafIdByPtyIdByTabId
|
||||
)
|
||||
const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
|
||||
const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([
|
||||
...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries),
|
||||
...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []),
|
||||
...normalizedSession.legacyPaneKeyAliasEntries
|
||||
...normalizedSession.legacyPaneKeyAliasEntries,
|
||||
...hostSessionLegacyPaneKeyAliasEntries
|
||||
])
|
||||
const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys(
|
||||
state.ui?.acknowledgedAgentsByPaneKey,
|
||||
normalizedSession.leafIdByInputLeafIdByTabId
|
||||
leafIdByInputLeafIdByTabId
|
||||
)
|
||||
const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual(
|
||||
state.migrationUnsupportedPtyEntries ?? [],
|
||||
@@ -139,6 +185,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
)
|
||||
if (
|
||||
!normalizedSession.changed &&
|
||||
!hostSessionsChanged &&
|
||||
!remappedLeases.changed &&
|
||||
!migrationUnsupportedChanged &&
|
||||
!legacyAliasesChanged &&
|
||||
@@ -155,6 +202,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
state: {
|
||||
...state,
|
||||
workspaceSession: normalizedSession.session,
|
||||
...(normalizedHostSessions ? { workspaceSessionsByHostId: normalizedHostSessions } : {}),
|
||||
sshRemotePtyLeases: remappedLeases.leases,
|
||||
migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries,
|
||||
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries,
|
||||
|
||||
@@ -82,6 +82,11 @@ export function updateAutomation(
|
||||
throw new Error('Automation not found.')
|
||||
}
|
||||
const current = operations.state.automations[index]
|
||||
// Why: the renderer forwards a Partial verbatim, so `{ enabled: undefined }` survives structuredClone
|
||||
// and would blank the stored value in the spread below. Explicit clears go through the `null` branches.
|
||||
const definedUpdates = Object.fromEntries(
|
||||
Object.entries(updates).filter(([, value]) => value !== undefined)
|
||||
) as AutomationUpdateInput
|
||||
const repoId = updates.projectId ?? current.projectId
|
||||
const repo = operations.state.repos.find((entry) => entry.id === repoId)
|
||||
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
|
||||
@@ -93,7 +98,7 @@ export function updateAutomation(
|
||||
const workspaceMode = updates.workspaceMode ?? current.workspaceMode
|
||||
const updated: Automation = {
|
||||
...current,
|
||||
...updates,
|
||||
...definedUpdates,
|
||||
name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name,
|
||||
precheck: Object.hasOwn(updates, 'precheck')
|
||||
? normalizeAutomationPrecheck(updates.precheck)
|
||||
|
||||
@@ -17,7 +17,7 @@ export function advanceAutomationNextRun(
|
||||
}
|
||||
const current = state.automations[index]
|
||||
const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now)
|
||||
const updated = { ...current, nextRunAt, updatedAt: Date.now() }
|
||||
const updated = { ...current, nextRunAt, updatedAt: now }
|
||||
state.automations[index] = updated
|
||||
flush()
|
||||
return updated
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../share
|
||||
import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime'
|
||||
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
|
||||
import { makeProjectHostSetupId } from './project-host-compatibility'
|
||||
import { repoGitUsernameCacheKey } from './repo-hydration'
|
||||
|
||||
export type ProjectHostMutationOperations = {
|
||||
state: StoreOwnedPersistedState
|
||||
@@ -214,8 +215,9 @@ export class ProjectHostPersistenceOperations {
|
||||
if (!repo) {
|
||||
return false
|
||||
}
|
||||
const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? ''
|
||||
this.gitUsernameCache.set(repo.path, username)
|
||||
const cacheKey = repoGitUsernameCacheKey(repo)
|
||||
const previous = this.gitUsernameCache.get(cacheKey) ?? repo.gitUsername ?? ''
|
||||
this.gitUsernameCache.set(cacheKey, username)
|
||||
if (previous === username) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { getRepoExecutionHostId } from '../../../shared/execution-host'
|
||||
import { getDefaultRepoHookSettings } from '../../../shared/constants'
|
||||
import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { sanitizeRepoIcon } from '../../../shared/repo-icon'
|
||||
@@ -14,6 +15,17 @@ import {
|
||||
normalizeWorktreeVisibilitySourcePreferences
|
||||
} from '../../../shared/worktree/visibility-sources'
|
||||
|
||||
/**
|
||||
* Cache key for a repo's resolved git username. Host-scoped because the same checkout path can exist
|
||||
* on local, SSH, and runtime hosts with different `user.name`, and a path-only key hydrates one
|
||||
* host's username onto another (wrong `git-username` branch prefix).
|
||||
*/
|
||||
export function repoGitUsernameCacheKey(
|
||||
repo: Pick<Repo, 'path' | 'connectionId' | 'executionHostId'>
|
||||
): string {
|
||||
return `${getRepoExecutionHostId(repo)}\u0000${repo.path}`
|
||||
}
|
||||
|
||||
export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap<string, string>): Repo {
|
||||
const {
|
||||
repoIcon: rawRepoIcon,
|
||||
@@ -41,7 +53,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap<string, st
|
||||
// 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 ?? '')
|
||||
: (gitUsernameCache.get(repoGitUsernameCacheKey(repo)) ?? repo.gitUsername ?? '')
|
||||
|
||||
return {
|
||||
...repoWithoutIcon,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources'
|
||||
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
|
||||
import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization'
|
||||
|
||||
@@ -82,12 +83,17 @@ export class RepoUpdatePersistenceOperations {
|
||||
(sanitizedUpdates.agentWorktreeVisibility === 'hide' ||
|
||||
sanitizedUpdates.agentWorktreeVisibility === 'show')
|
||||
) {
|
||||
sanitizedUpdates.worktreeVisibilitySourcePreferences = {
|
||||
// Why normalize: the stored value is spread in as-is, so a legacy/corrupt custom map would be
|
||||
// written straight back without passing the same validation as a renderer-supplied patch.
|
||||
const preferences = normalizeWorktreeVisibilitySourcePreferences({
|
||||
...repo.worktreeVisibilitySourcePreferences,
|
||||
builtIn: {
|
||||
claude: sanitizedUpdates.agentWorktreeVisibility,
|
||||
gsd: sanitizedUpdates.agentWorktreeVisibility
|
||||
}
|
||||
})
|
||||
if (preferences) {
|
||||
sanitizedUpdates.worktreeVisibilitySourcePreferences = preferences
|
||||
}
|
||||
}
|
||||
if ('projectGroupId' in sanitizedUpdates) {
|
||||
|
||||
@@ -30,18 +30,9 @@ export function pruneWorktreeStateForRepo(
|
||||
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.)
|
||||
// Why: leftover session owner keys re-materialize into worktreeMeta next launch as orphaned
|
||||
// "unknown" workspaces. Owner keys carry no host and can repeat across partitions, so collect every
|
||||
// prefix match (before the worktreeMeta deletes below) and let the per-partition gating decide.
|
||||
const ownerKeysToPrune = new Set<string>()
|
||||
const collectPrefixedKeys = (keys: Iterable<string>): void => {
|
||||
for (const key of keys) {
|
||||
@@ -61,13 +52,9 @@ export function pruneWorktreeStateForRepo(
|
||||
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.
|
||||
// Why: a host-scoped prune must touch only that host's session (legacy blob = local, one partition
|
||||
// per remote host); pruning every partition would wipe a surviving host's tabs and sleeping agents
|
||||
// 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) {
|
||||
|
||||
@@ -159,6 +159,9 @@ export function migrateWorktreeIdentity(
|
||||
const movedLineage = state.worktreeLineageById[newWorktreeId]
|
||||
if (movedLineage && movedLineage.worktreeId === oldWorktreeId) {
|
||||
movedLineage.worktreeId = newWorktreeId
|
||||
// Why: moveKey reports nothing when the record already sat under the new key, so flag the repair
|
||||
// ourselves or the caller skips the save and the stale id comes back on reload.
|
||||
changed = true
|
||||
}
|
||||
// Why: children carry this as parentWorktreeId; keep the denormalized path-derived id consistent (parentWorktreeInstanceId is stable).
|
||||
for (const lineage of Object.values(state.worktreeLineageById)) {
|
||||
|
||||
@@ -23,7 +23,10 @@ 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.
|
||||
// Companion lineage maps get the same guard because the deletes below index them directly.
|
||||
state.worktreeMeta ??= {}
|
||||
state.worktreeLineageById ??= {}
|
||||
state.workspaceLineageByChildKey ??= {}
|
||||
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()
|
||||
@@ -79,14 +82,24 @@ export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number {
|
||||
|
||||
export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean {
|
||||
let changed = false
|
||||
// Why: a hand-corrupted null lineage map would throw on the companion deletes below.
|
||||
state.worktreeLineageById ??= {}
|
||||
state.workspaceLineageByChildKey ??= {}
|
||||
const rawWorktreeMeta = state.worktreeMeta as unknown
|
||||
if (
|
||||
typeof rawWorktreeMeta !== 'object' ||
|
||||
rawWorktreeMeta === null ||
|
||||
Array.isArray(rawWorktreeMeta)
|
||||
) {
|
||||
const hadLineage =
|
||||
Object.keys(state.worktreeLineageById).length > 0 ||
|
||||
Object.keys(state.workspaceLineageByChildKey).length > 0
|
||||
state.worktreeMeta = {}
|
||||
changed = rawWorktreeMeta !== undefined
|
||||
// Companions go with the discarded map; a stranded lineage row would otherwise re-attach to a
|
||||
// worktree recreated at the same repoId::path.
|
||||
state.worktreeLineageById = {}
|
||||
state.workspaceLineageByChildKey = {}
|
||||
changed = rawWorktreeMeta !== undefined || hadLineage
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user