mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(persistence): keep the lineage maps when a profile file has no worktreeMeta key
The rebuild returned `parsed.worktreeMeta` untouched when it was not a plain
record, so an absent key became an explicit `worktreeMeta: undefined` that
outranked the defaults spread. `normalizeWorktreeLinkedItemMetadata` reads a
non-object `worktreeMeta` as corruption and wipes that file's
`worktreeLineageById` and `workspaceLineageByChildKey` with it, then marks the
state dirty so the wipe is persisted. Before this branch the spread supplied
`{}` and the lineage survived.
Also pins the two raw-file readers the projection made load-bearing: the
history GC recovering projected ids from the alias keys (a miss deletes shell
history a live workspace is using), and the profile-transfer read rebuilding
the omitted locator rows (a miss transfers workspaces with no metadata).
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultPersistedState } from '../../shared/constants'
|
||||
import type { PersistedState } from '../../shared/persisted-state-types'
|
||||
import type { Project, ProjectHostSetup } from '../../shared/project-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { rebuildRepoBackedProjectState } from './profile-project-state-file'
|
||||
import { readProfileState, rebuildRepoBackedProjectState } from './profile-project-state-file'
|
||||
|
||||
const upstreamIdentity = {
|
||||
canonicalKey: 'git.example.com/acme/app-upstream',
|
||||
@@ -153,3 +156,57 @@ describe('rebuildRepoBackedProjectState', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A profile transfer reads another profile's file directly, bypassing the Store's load path. The
|
||||
* serializer omits any `worktreeMeta` row the identity map can rebuild, so without the rebuild here
|
||||
* every transferred workspace would arrive at the target profile stripped of its metadata.
|
||||
*/
|
||||
describe('readProfileState on a projected profile file', () => {
|
||||
function seedProfile(state: unknown): { userDataPath: string; profileId: string } {
|
||||
const userDataPath = realpathSync(mkdtempSync(join(tmpdir(), 'orca-profile-read-')))
|
||||
const profileId = 'source'
|
||||
mkdirSync(join(userDataPath, 'profiles', profileId), { recursive: true })
|
||||
writeFileSync(
|
||||
join(userDataPath, 'profiles', profileId, 'orca-data.json'),
|
||||
JSON.stringify(state),
|
||||
'utf-8'
|
||||
)
|
||||
return { userDataPath, profileId }
|
||||
}
|
||||
|
||||
const identityRow = {
|
||||
hostId: 'local',
|
||||
instanceId: 'inst-1',
|
||||
displayName: 'projected workspace',
|
||||
linkedPR: 41
|
||||
}
|
||||
|
||||
it('rebuilds the locator rows the serializer left to the identity map', () => {
|
||||
const { userDataPath, profileId } = seedProfile({
|
||||
repos: [makeRepo({ id: 'repo', path: '/repo' })],
|
||||
worktreeMeta: {},
|
||||
worktreeMetaByIdentity: { 'wt2:local:inst-1': identityRow },
|
||||
worktreeIdentityAliases: { 'local|repo::/projected': ['wt2:local:inst-1'] },
|
||||
worktreeMetaAliasesWithoutLegacyRow: []
|
||||
})
|
||||
|
||||
const state = readProfileState(profileId, userDataPath)
|
||||
|
||||
expect(state.worktreeMeta['repo::/projected']).toEqual(identityRow)
|
||||
// Serialize-only: it must never be carried into the state a transfer writes back.
|
||||
expect(state.worktreeMetaAliasesWithoutLegacyRow).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a genuinely removed locator removed', () => {
|
||||
const { userDataPath, profileId } = seedProfile({
|
||||
repos: [makeRepo({ id: 'repo', path: '/repo' })],
|
||||
worktreeMeta: {},
|
||||
worktreeMetaByIdentity: { 'wt2:local:inst-1': identityRow },
|
||||
worktreeIdentityAliases: { 'local|repo::/projected': ['wt2:local:inst-1'] },
|
||||
worktreeMetaAliasesWithoutLegacyRow: ['local|repo::/projected']
|
||||
})
|
||||
|
||||
expect(readProfileState(profileId, userDataPath).worktreeMeta).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,7 +56,13 @@ export function normalizeLoadedProfileState(
|
||||
),
|
||||
// Rebuilds the locator rows the serializer left to the identity map, and restores the shared
|
||||
// object reference JSON.parse splits. Not `markNeedsSave`: this IS the canonical on-disk shape.
|
||||
worktreeMeta: hydrateWorktreeMetaAliasProjection(parsed),
|
||||
// Conditional so an absent key stays absent and the defaults spread still supplies `{}`: an
|
||||
// explicit `worktreeMeta: undefined` outranks the spread and sends
|
||||
// `normalizeWorktreeLinkedItemMetadata` down its corrupt-value branch, which wipes the file's
|
||||
// `worktreeLineageById` / `workspaceLineageByChildKey` and persists the wipe.
|
||||
...(parsed.worktreeMeta === undefined
|
||||
? {}
|
||||
: { worktreeMeta: hydrateWorktreeMetaAliasProjection(parsed) }),
|
||||
worktreeMetaAliasesWithoutLegacyRow: undefined,
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
|
||||
|
||||
@@ -310,6 +310,43 @@ describe('worktree meta alias projection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// The rebuild must not materialize a `worktreeMeta` key the file did not have: an explicit
|
||||
// `undefined` outranks the defaults spread, and `normalizeWorktreeLinkedItemMetadata` reads a
|
||||
// non-object `worktreeMeta` as corruption and wipes the lineage maps with it — then persists it.
|
||||
it('keeps the lineage maps when the file carries no worktreeMeta key at all', () => {
|
||||
const dataFile = tempDataFile()
|
||||
writeFileSync(
|
||||
dataFile,
|
||||
JSON.stringify({
|
||||
repos: [{ id: REPO_ID, name: REPO_ID, path: '/tmp/repo-1', worktreesPath: '/tmp' }],
|
||||
worktreeLineageById: {
|
||||
[`${REPO_ID}::/tmp/child`]: {
|
||||
parentWorktreeId: `${REPO_ID}::/tmp/parent`,
|
||||
createdAt: RECENTLY
|
||||
}
|
||||
},
|
||||
workspaceLineageByChildKey: {
|
||||
[`worktree:${REPO_ID}::/tmp/child`]: {
|
||||
parentWorkspaceKey: `worktree:${REPO_ID}::/tmp/parent`,
|
||||
createdAt: RECENTLY
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const store = openStore(dataFile)
|
||||
|
||||
expect(Object.keys(store.getAllWorktreeLineage())).toEqual([`${REPO_ID}::/tmp/child`])
|
||||
expect(store.getAllWorktreeMeta()).toEqual({})
|
||||
store.flush()
|
||||
const onDisk = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState
|
||||
expect(Object.keys(onDisk.worktreeLineageById)).toEqual([`${REPO_ID}::/tmp/child`])
|
||||
expect(Object.keys(onDisk.workspaceLineageByChildKey)).toEqual([
|
||||
`worktree:${REPO_ID}::/tmp/child`
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps every row when the alias map is missing or unreadable', () => {
|
||||
for (const aliases of [undefined, null, [], { 'local|x': 'not-an-array' }]) {
|
||||
const fixture = buildFixture()
|
||||
|
||||
@@ -75,6 +75,30 @@ describe('getOtherProfileWorktreeIdsForHistoryGc', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// The serializer omits any `worktreeMeta` row the identity map can rebuild, so on a projected
|
||||
// file the alias keys are the only place those ids appear — and this reader is what stands
|
||||
// between them and a delete of shell history the workspace is still using.
|
||||
it('recovers the ids of locator rows the serializer projected onto the identity map', () => {
|
||||
const root = userDataWithProfiles('active', [
|
||||
{ id: 'active', state: { worktreeMeta: {} } },
|
||||
{
|
||||
id: 'other',
|
||||
state: {
|
||||
// Exactly what a projected save writes: the twin row left the locator map entirely.
|
||||
worktreeMeta: { 'repo::/kept': {} },
|
||||
worktreeMetaByIdentity: { 'wt2:local:inst-1': { hostId: 'local', instanceId: 'inst-1' } },
|
||||
worktreeIdentityAliases: { 'local|repo::/projected': ['wt2:local:inst-1'] },
|
||||
worktreeMetaAliasesWithoutLegacyRow: []
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const result = getOtherProfileWorktreeIdsForHistoryGc(root)
|
||||
|
||||
expect(result.unreadableProfiles).toBe(0)
|
||||
expect(result.ids).toEqual(new Set(['repo::/kept', 'repo::/projected']))
|
||||
})
|
||||
|
||||
// The active profile's ids come from the live Store, which is authoritative;
|
||||
// re-reading its file would only race a write in progress.
|
||||
it('skips the active profile', () => {
|
||||
|
||||
Reference in New Issue
Block a user