mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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' })],
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user