fix(store): keep project catalog identity when repo.addedAt is 0

projectHostSetupProjectionFromRepos used `repo.addedAt || now`, so a
missing or zero addedAt stamped Date.now() into createdAt/updatedAt on
every refresh. #13803's reconcile then treated the project as changed
and never reused it.

Use a finite check with a stable 0 fallback, and treat 0 as unknown in
merge so a persisted createdAt is not wiped. Refresh-identity tests use
production-shaped nested fixtures plus structuredClone and go red if
the fallback is reverted.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-13 00:18:58 -07:00
co-authored by Orca
parent ac68eead64
commit 2ae61e6721
3 changed files with 120 additions and 11 deletions
@@ -52,10 +52,15 @@ function clone<T>(value: T): T {
return structuredClone(value)
}
function mockRepos(...rows: readonly Repo[]): void {
function mockRepos(...rows: readonly (Repo | Omit<Repo, 'addedAt'>)[]): void {
reposList.mockImplementation(async () => rows.map(clone))
}
function omitAddedAt(row: Repo): Omit<Repo, 'addedAt'> {
const { addedAt: _addedAt, ...rest } = row
return rest
}
beforeEach(() => {
reposList.mockReset()
projectsList.mockReset()
@@ -236,6 +241,62 @@ describe('repo catalog refresh identity', () => {
expect(store.getState().projects).toHaveLength(2)
expect(store.getState().projectHostSetups).toHaveLength(2)
})
it('keeps catalog identity when repo.addedAt is 0', async () => {
mockRepos({ ...repo, addedAt: 0 })
const store = createTestStore()
await store.getState().fetchRepos()
const projects = store.getState().projects
const setups = store.getState().projectHostSetups
expect(projects).toHaveLength(1)
expect(setups).toHaveLength(1)
expect(projects[0]?.createdAt).toBe(0)
expect(setups[0]?.createdAt).toBe(0)
await store.getState().fetchRepos()
expect(store.getState().projects).toBe(projects)
expect(store.getState().projects[0]).toBe(projects[0])
expect(store.getState().projectHostSetups).toBe(setups)
expect(store.getState().projectHostSetups[0]).toBe(setups[0])
})
it('keeps catalog identity when repo.addedAt is omitted', async () => {
mockRepos(omitAddedAt(repo))
const store = createTestStore()
await store.getState().fetchRepos()
const projects = store.getState().projects
const setups = store.getState().projectHostSetups
expect(projects).toHaveLength(1)
expect(setups).toHaveLength(1)
expect(projects[0]?.createdAt).toBe(0)
expect(setups[0]?.createdAt).toBe(0)
await store.getState().fetchRepos()
expect(store.getState().projects).toBe(projects)
expect(store.getState().projects[0]).toBe(projects[0])
expect(store.getState().projectHostSetups).toBe(setups)
expect(store.getState().projectHostSetups[0]).toBe(setups[0])
})
it('lets a nested hookSettings change through when repo.addedAt is 0', async () => {
mockRepos({ ...repo, addedAt: 0 })
const store = createTestStore()
await store.getState().fetchRepos()
const setups = store.getState().projectHostSetups
mockRepos({
...repo,
addedAt: 0,
hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } }
})
await store.getState().fetchRepos()
expect(store.getState().projectHostSetups).not.toBe(setups)
expect(store.getState().projectHostSetups[0]).not.toBe(setups[0])
expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override')
})
})
describe('repo filter identity across catalog refreshes', () => {
@@ -19,6 +19,44 @@ function repo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path' | 'displayName
}
describe('project host setup projection', () => {
it('keeps timestamps stable when addedAt is 0 across different now values', () => {
const target = repo({ id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca', addedAt: 0 })
const first = projectHostSetupProjectionFromRepos([target], 1_000)
const second = projectHostSetupProjectionFromRepos([target], 9_999)
expect(first.projects[0]?.createdAt).toBe(0)
expect(first.projects[0]?.updatedAt).toBe(0)
expect(first.setups[0]?.createdAt).toBe(0)
expect(second.projects[0]?.createdAt).toBe(first.projects[0]?.createdAt)
expect(second.projects[0]?.updatedAt).toBe(first.projects[0]?.updatedAt)
expect(second.setups[0]?.createdAt).toBe(first.setups[0]?.createdAt)
expect(second.setups[0]?.updatedAt).toBe(first.setups[0]?.updatedAt)
})
it('does not wipe a persisted createdAt when a sibling repo has addedAt 0', () => {
const projection = projectHostSetupProjectionFromRepos([
repo({
id: 'local-repo',
path: '/Users/alice/orca',
displayName: 'Orca',
addedAt: 100,
upstream: { owner: 'StablyAI', repo: 'Orca' }
}),
repo({
id: 'remote-repo',
path: '/home/alice/orca',
displayName: 'orca',
addedAt: 0,
connectionId: 'gpu-vm',
upstream: { owner: 'stablyai', repo: 'orca' }
})
])
expect(projection.projects).toHaveLength(1)
expect(projection.projects[0]?.createdAt).toBe(100)
expect(projection.projects[0]?.updatedAt).toBe(100)
})
it('projects a legacy local repo into one project and one ready local setup', () => {
const projection = projectHostSetupProjectionFromRepos(
[repo({ id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca' })],
+20 -10
View File
@@ -205,9 +205,16 @@ function parseGitHubRemoteUrl(remoteUrl: string | undefined): ProjectProviderIde
}
}
function createProjectFromRepo(repo: Repo, now: number): Project {
// Why: `addedAt || now` restamps Date.now() when addedAt is 0 / absent / NaN, so every
// projection looks dirty and reconcileCatalogRows never reuses the project or setup.
function catalogTimestampFromAddedAt(addedAt: number): number {
return Number.isFinite(addedAt) ? addedAt : 0
}
function createProjectFromRepo(repo: Repo): Project {
const identity = getProjectProviderIdentity(repo)
const gitRemoteIdentity = getProjectGitRemoteIdentity(repo)
const addedAt = catalogTimestampFromAddedAt(repo.addedAt)
return {
id: getProjectId(repo),
displayName: repo.displayName,
@@ -217,8 +224,8 @@ function createProjectFromRepo(repo: Repo, now: number): Project {
...(identity ? { providerIdentity: identity } : {}),
...(gitRemoteIdentity ? { gitRemoteIdentity } : {}),
sourceRepoIds: [repo.id],
createdAt: repo.addedAt || now,
updatedAt: repo.addedAt || now
createdAt: addedAt,
updatedAt: addedAt
}
}
@@ -226,17 +233,20 @@ function mergeProjectRepo(project: Project, repo: Repo): Project {
const sourceRepoIds = project.sourceRepoIds.includes(repo.id)
? project.sourceRepoIds
: [...project.sourceRepoIds, repo.id]
// Why: 0 / absent / NaN means "unknown", not epoch — min() would wipe a persisted createdAt.
const addedAt =
Number.isFinite(repo.addedAt) && repo.addedAt !== 0 ? repo.addedAt : undefined
return {
...project,
sourceRepoIds,
createdAt: Math.min(project.createdAt, repo.addedAt || project.createdAt),
updatedAt: Math.max(project.updatedAt, repo.addedAt || project.updatedAt)
createdAt: Math.min(project.createdAt, addedAt ?? project.createdAt),
updatedAt: Math.max(project.updatedAt, addedAt ?? project.updatedAt)
}
}
function createSetupFromRepo(repo: Repo, projectId: string, now: number): ProjectHostSetup {
function createSetupFromRepo(repo: Repo, projectId: string): ProjectHostSetup {
const hostId = getRepoExecutionHostId(repo)
const createdAt = repo.addedAt || now
const createdAt = catalogTimestampFromAddedAt(repo.addedAt)
const setupMethod = repo.projectHostSetupMethod ?? 'legacy-repo'
return {
id: repo.id,
@@ -261,7 +271,7 @@ function createSetupFromRepo(repo: Repo, projectId: string, now: number): Projec
export function projectHostSetupProjectionFromRepos(
repos: readonly Repo[],
now = Date.now()
_now?: number
): ProjectHostSetupProjection {
const projectById = new Map<string, ProjectAccumulator>()
const setups: ProjectHostSetup[] = []
@@ -271,8 +281,8 @@ export function projectHostSetupProjectionFromRepos(
const existing = projectById.get(projectId)
const project = existing
? mergeProjectRepo(existing.project, repo)
: createProjectFromRepo(repo, now)
const setup = createSetupFromRepo(repo, projectId, now)
: createProjectFromRepo(repo)
const setup = createSetupFromRepo(repo, projectId)
projectById.set(projectId, {
project
})