mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(store): keep setup-script dismissal array identity on no-op repo fetches
* fix(store): keep setup-script dismissal array identity on no-op repo fetches filterSetupScriptPromptDismissalsToValidRepos always allocated a new array, so fetchRepos, fetchRuntimeEnvironmentRepos, and validateRepoScopedUi replaced setupScriptPromptDismissedRepoIds even when every entry was already a valid host-identity key. SetupScriptPromptCard Object.is-subscribes to that array, so a no-op catalog refresh missed 100% of the time. Return the original store array when the filtered result is element-wise identical to the input. Allocate only when an entry is dropped, rewritten from a legacy generation-v1:repoId key, or deduped. Call sites already assign the helper result. Co-authored-by: Orca <help@stably.ai> * type: make setupScriptPromptDismissedRepoIds readonly Return readonly string[] from filterSetupScriptPromptDismissalsToValidRepos and sanitizeSetupScriptPromptDismissals, and update the store field type to readonly. This prevents accidental mutation of the live store array on no-op prune cycles. Add isUnchangedDismissalList type predicate for identity check. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -202,6 +202,59 @@ describe('setup script prompt inspection', () => {
|
||||
).toEqual([getSetupScriptPromptDismissalKey(localIdentity)])
|
||||
})
|
||||
|
||||
it('reuses the input array when it is empty', () => {
|
||||
const input: string[] = []
|
||||
expect(
|
||||
filterSetupScriptPromptDismissalsToValidRepos(
|
||||
input,
|
||||
new Set([getRepoHostIdentityForParts('repo-1', 'local')])
|
||||
)
|
||||
).toBe(input)
|
||||
})
|
||||
|
||||
it('reuses the input array when every dismissal is already a valid host-identity key', () => {
|
||||
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
|
||||
const remoteIdentity = getRepoHostIdentityForParts('repo-2', 'ssh:host-a')
|
||||
const input = [
|
||||
getSetupScriptPromptDismissalKey(localIdentity),
|
||||
getSetupScriptPromptDismissalKey(remoteIdentity)
|
||||
]
|
||||
expect(
|
||||
filterSetupScriptPromptDismissalsToValidRepos(
|
||||
input,
|
||||
new Set([localIdentity, remoteIdentity])
|
||||
)
|
||||
).toBe(input)
|
||||
})
|
||||
|
||||
it('allocates when a legacy repo-id dismissal is rewritten to a host identity', () => {
|
||||
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
|
||||
const input = [getSetupScriptPromptDismissalKey('repo-1')]
|
||||
const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity]))
|
||||
expect(result).not.toBe(input)
|
||||
expect(result).toEqual([getSetupScriptPromptDismissalKey(localIdentity)])
|
||||
})
|
||||
|
||||
it('allocates when a stale dismissal is dropped', () => {
|
||||
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
|
||||
const input = [
|
||||
getSetupScriptPromptDismissalKey(localIdentity),
|
||||
getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts('gone', 'local'))
|
||||
]
|
||||
const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity]))
|
||||
expect(result).not.toBe(input)
|
||||
expect(result).toEqual([getSetupScriptPromptDismissalKey(localIdentity)])
|
||||
})
|
||||
|
||||
it('allocates when a duplicate valid dismissal is deduped', () => {
|
||||
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
|
||||
const key = getSetupScriptPromptDismissalKey(localIdentity)
|
||||
const input = [key, key]
|
||||
const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity]))
|
||||
expect(result).not.toBe(input)
|
||||
expect(result).toEqual([key])
|
||||
})
|
||||
|
||||
it('drops a legacy repo-id dismissal when that id exists on multiple hosts', () => {
|
||||
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
|
||||
const remoteIdentity = getRepoHostIdentityForParts('repo-1', 'runtime:windows')
|
||||
|
||||
@@ -90,10 +90,21 @@ export function isSetupScriptPromptDismissed(
|
||||
return dismissedEntries.includes(getSetupScriptPromptDismissalKey(repoHostIdentity))
|
||||
}
|
||||
|
||||
function isUnchangedDismissalList(
|
||||
value: unknown,
|
||||
next: readonly string[]
|
||||
): value is readonly string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === next.length &&
|
||||
value.every((entry, index) => entry === next[index])
|
||||
)
|
||||
}
|
||||
|
||||
export function filterSetupScriptPromptDismissalsToValidRepos(
|
||||
value: unknown,
|
||||
validRepoHostIdentities: Set<string>
|
||||
): string[] {
|
||||
): readonly string[] {
|
||||
const unambiguousIdentityByRepoId = new Map<string, string | null>()
|
||||
for (const identity of validRepoHostIdentities) {
|
||||
const separatorIndex = identity.indexOf('\0')
|
||||
@@ -117,10 +128,18 @@ export function filterSetupScriptPromptDismissalsToValidRepos(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: fetchRepos / fetchRuntimeEnvironmentRepos / validateRepoScopedUi assign
|
||||
// this into set() on every catalog refresh. SetupScriptPromptCard Object.is-
|
||||
// subscribes to the array, so a fresh copy on a no-op prune is a guaranteed miss.
|
||||
// Compare against the original input, not the sanitize copy — sanitize always
|
||||
// allocates, including for [] and already-valid host-identity keys.
|
||||
if (isUnchangedDismissalList(value, next)) {
|
||||
return value
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function sanitizeSetupScriptPromptDismissals(value: unknown): string[] {
|
||||
export function sanitizeSetupScriptPromptDismissals(value: unknown): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Project, Repo } from '../../../../shared/types'
|
||||
import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt'
|
||||
import { getRepoHostIdentityForParts } from './repo-host-identity'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
// Why: every field here is load-bearing. A scalar-only repo reconciles even when the structural
|
||||
@@ -281,3 +283,21 @@ describe('repo filter identity across catalog refreshes', () => {
|
||||
expect(store.getState().filterRepoIds).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setup-script dismissal identity across catalog refreshes', () => {
|
||||
it('keeps the dismissal array when a refetch prunes nothing', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
setupScriptPromptDismissedRepoIds: [
|
||||
getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts(repo.id, 'local'))
|
||||
]
|
||||
})
|
||||
const first = store.getState().setupScriptPromptDismissedRepoIds
|
||||
|
||||
await store.getState().fetchRepos()
|
||||
|
||||
// Why: SetupScriptPromptCard Object.is-subscribes to this array. A no-op
|
||||
// catalog refresh must not allocate just because the helper rebuilt next.
|
||||
expect(store.getState().setupScriptPromptDismissedRepoIds).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -853,7 +853,7 @@ export type UISlice = {
|
||||
) => void
|
||||
markOrcaHookRepoAlwaysTrusted: (repoId: string) => void
|
||||
clearOrcaHookTrustForRepo: (repoId: string) => void
|
||||
setupScriptPromptDismissedRepoIds: string[]
|
||||
setupScriptPromptDismissedRepoIds: readonly string[]
|
||||
dismissSetupScriptPrompt: (repoHostIdentity: string) => void
|
||||
setupGuideSidebarDismissed: boolean
|
||||
setSetupGuideSidebarDismissed: (dismissed: boolean) => void
|
||||
|
||||
Reference in New Issue
Block a user