mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(sidebar-inputs): republish workspace rows on visibility settings saves; fence pre-gap paired-server answers (#31106)
A settings save that changes what the visibility filter reads (external/agent defaults, workspace folder and its history, nesting) now moves the workspace rows revision, whichever path wrote it. A paired-server answer to a fetch that started before a contact gap keeps its rows but no longer marks them live. Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
@@ -108,6 +108,7 @@ export type RuntimeStore = {
|
||||
nestedWorkerMaxDepth?: GlobalSettings['nestedWorkerMaxDepth']
|
||||
workspaceDir: string
|
||||
nestWorkspaces: boolean
|
||||
workspaceDirHistory?: GlobalSettings['workspaceDirHistory']
|
||||
// Read by worktree placement: decides whether this project's worktrees
|
||||
// mirror into a WSL distro instead of the Windows drive.
|
||||
localWindowsRuntimeDefault?: GlobalSettings['localWindowsRuntimeDefault']
|
||||
|
||||
@@ -458,6 +458,40 @@ describe('PairedServerRowsProducer', () => {
|
||||
expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('live')
|
||||
})
|
||||
|
||||
it('never certifies rows live from a fetch that started before a contact gap', async () => {
|
||||
const { hosts, call, watches, state, publishStatus } = fakeHosts([{ id: 'box', live: true }])
|
||||
let answerBefore: (response: RuntimeRpcResponse<unknown>) => void = () => {}
|
||||
let answerAfter: (response: RuntimeRpcResponse<unknown>) => void = () => {}
|
||||
call.mockReturnValueOnce(new Promise((resolve) => (answerBefore = resolve)))
|
||||
call.mockReturnValueOnce(new Promise((resolve) => (answerAfter = resolve)))
|
||||
const producer = new PairedServerRowsProducer({
|
||||
hasDemand: () => true,
|
||||
hostLabelOverrides: () => new Map()
|
||||
})
|
||||
producer.attach(hosts)
|
||||
watches.get('box')?.onChange()
|
||||
|
||||
state.servers = [{ id: 'box', live: false }]
|
||||
publishStatus()
|
||||
state.servers = [{ id: 'box', live: true }]
|
||||
publishStatus()
|
||||
watches.get('box')?.onChange()
|
||||
answerBefore(psReply([row('deleted-during-gap')]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
expect(rowsOf(producer, 'runtime:box')).toMatchObject({
|
||||
freshness: 'unverifiable',
|
||||
rows: [{ worktreeId: 'deleted-during-gap' }]
|
||||
})
|
||||
|
||||
answerAfter(psReply([row('current')]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rowsOf(producer, 'runtime:box')).toMatchObject({
|
||||
freshness: 'live',
|
||||
rows: [{ worktreeId: 'current' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a new watch once the old one ends with its transport, even without a close notice', () => {
|
||||
const { hosts, watches, publishStatus } = fakeHosts([{ id: 'box', live: true }])
|
||||
const producer = new PairedServerRowsProducer({
|
||||
|
||||
@@ -31,6 +31,8 @@ type ServerEntry = {
|
||||
fence: string
|
||||
identity: Pick<MobileDesktopRelayHostListing, 'pairingRevision' | 'runtimeId'>
|
||||
inContact: boolean
|
||||
/** Moves on every contact change, so an answer can tell it predates a gap. */
|
||||
contactGeneration: number
|
||||
/** Null until a fetch under this pairing answered. */
|
||||
rows: MobileRelayServerWorktreeRow[] | null
|
||||
/** The rows are the server's answer since it last came into contact; else unverifiable. */
|
||||
@@ -44,7 +46,7 @@ type ServerEntry = {
|
||||
* The desktop's one live set of paired-server workspace rows, needing no window. A server is
|
||||
* refetched when it comes into contact, on each of its change events, when a reader asks, and
|
||||
* every 30 s while the list is showing. A server's last rows read unverifiable once it loses
|
||||
* contact, until a fetch answers again. Only removing or re-pairing a server drops them; a fetch
|
||||
* contact, until a fetch started since it came back answers. Only removing or re-pairing a server drops them; a fetch
|
||||
* that answers replaces them, deletions included.
|
||||
*/
|
||||
export class PairedServerRowsProducer implements ServerWorktreeRows {
|
||||
@@ -112,6 +114,7 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
|
||||
fence: relayHostFence(host.identity),
|
||||
identity: host.identity,
|
||||
inContact: false,
|
||||
contactGeneration: 0,
|
||||
rows: null,
|
||||
confirmed: false,
|
||||
watch: null,
|
||||
@@ -125,6 +128,7 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
|
||||
const inContact = shown.get(entry.environmentId)?.health === 'available'
|
||||
if (inContact !== entry.inContact) {
|
||||
entry.inContact = inContact
|
||||
entry.contactGeneration += 1
|
||||
entry.confirmed = false
|
||||
this.revision.bump()
|
||||
}
|
||||
@@ -222,6 +226,7 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
|
||||
}
|
||||
|
||||
private async fetch(hosts: MobileDesktopRelayHosts, entry: ServerEntry): Promise<void> {
|
||||
const { contactGeneration } = entry
|
||||
let response: Awaited<ReturnType<MobileDesktopRelayHosts['call']>> | null = null
|
||||
try {
|
||||
response = await hosts.call(
|
||||
@@ -244,7 +249,8 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
|
||||
return
|
||||
}
|
||||
entry.rows = stampServerWorktreeRows(entry.environmentId, reply.data.worktrees)
|
||||
entry.confirmed = entry.inContact
|
||||
// An answer from before a contact gap may keep its rows, but cannot vouch for them now.
|
||||
entry.confirmed = entry.inContact && entry.contactGeneration === contactGeneration
|
||||
// Every answer moves the revision: rows carry activity that changes without a catalog event.
|
||||
this.revision.bump()
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ function resolved(id: string, repo: Repo, hostId: ResolvedWorktree['hostId']): R
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
function setup(repos: Repo[] = [localRepo, sshRepo]) {
|
||||
let settings: Partial<ReturnType<RuntimeStore['getSettings']>> = {}
|
||||
let onWrite: (domains: readonly string[] | undefined) => void = () => {}
|
||||
const store: Pick<
|
||||
RuntimeStore,
|
||||
@@ -69,8 +70,8 @@ function setup() {
|
||||
| 'getSettings'
|
||||
| 'onStateWritten'
|
||||
> = {
|
||||
getRepos: () => [localRepo, sshRepo],
|
||||
getRepo: (id) => [localRepo, sshRepo].find((repo) => repo.id === id),
|
||||
getRepos: () => repos,
|
||||
getRepo: (id) => repos.find((repo) => repo.id === id),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getFolderWorkspaces: () => [],
|
||||
@@ -78,7 +79,7 @@ function setup() {
|
||||
getSshTargets: () => [],
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the visibility filter reads only absent fields as defaults.
|
||||
getSettings: () => ({}) as ReturnType<RuntimeStore['getSettings']>,
|
||||
getSettings: () => settings as ReturnType<RuntimeStore['getSettings']>,
|
||||
onStateWritten: (listener) => {
|
||||
onWrite = listener
|
||||
return () => {}
|
||||
@@ -112,7 +113,12 @@ function setup() {
|
||||
runtime,
|
||||
rows: requireSwitchedOn(runtime.workspaceRows),
|
||||
listing,
|
||||
write: (domains?: readonly string[]) => onWrite(domains)
|
||||
write: (domains?: readonly string[]) => onWrite(domains),
|
||||
// As the store does: replace the settings in memory, then report a settings-tagged write.
|
||||
writeSettings: (updates: Partial<ReturnType<RuntimeStore['getSettings']>>) => {
|
||||
settings = { ...settings, ...updates }
|
||||
onWrite(['settings'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +141,7 @@ describe('WorkspaceRowsProducer', () => {
|
||||
expect(snapshot['ssh:box|repo-2::/remote/b']).toMatchObject({ hostId: 'ssh:box', branch: '' })
|
||||
})
|
||||
|
||||
it('moves on catalog changes and metadata writes, never on session or settings writes', () => {
|
||||
it('moves on catalog changes and metadata writes, never on session or unrelated settings writes', () => {
|
||||
const { runtime, rows, write } = setup()
|
||||
const at = (): number => rows.revision.value
|
||||
const start = at()
|
||||
@@ -151,6 +157,48 @@ describe('WorkspaceRowsProducer', () => {
|
||||
expect(at()).toBe(start + 4)
|
||||
})
|
||||
|
||||
it('moves when a settings write changes what the visibility filter reads, and only then', () => {
|
||||
const { rows, writeSettings } = setup()
|
||||
const at = (): number => rows.revision.value
|
||||
const start = at()
|
||||
writeSettings({ compactWorktreeCards: true })
|
||||
expect(at()).toBe(start)
|
||||
writeSettings({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
expect(at()).toBe(start + 1)
|
||||
writeSettings({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
expect(at()).toBe(start + 1)
|
||||
writeSettings({ workspaceDir: '/w' })
|
||||
writeSettings({ nestWorkspaces: false })
|
||||
writeSettings({ workspaceDirHistory: [{ path: '/old', nestWorkspaces: true }] })
|
||||
expect(at()).toBe(start + 4)
|
||||
})
|
||||
|
||||
it('republishes rows to the feed when a visibility setting is saved', async () => {
|
||||
// Added after the rollout, so the global default decides whether an external checkout shows.
|
||||
const recentRepo: Repo = { ...localRepo, id: 'repo-3', externalWorktreeVisibilityLegacy: false }
|
||||
const { runtime, listing, writeSettings } = setup([localRepo, sshRepo, recentRepo])
|
||||
listing.worktrees.push(resolved('repo-3::/external', recentRepo, 'local'))
|
||||
writeSettings({ worktreeVisibilityDefaults: { external: 'hide' } })
|
||||
const feed = requireSwitchedOn(runtime.sidebarInputsFeed)
|
||||
const events: RuntimeClientEvent[] = []
|
||||
runtime.onClientEvent((event) => events.push(event))
|
||||
const phone = new TestPhone(feed, 'conn-1')
|
||||
phone.refresh()
|
||||
await phone.settle()
|
||||
expect(Object.keys(phone.view().workspaces)).not.toContain('local|repo-3::/external')
|
||||
|
||||
writeSettings({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
const notice = events.findLast((event) => event.type === 'sidebarInputsChanged')
|
||||
if (notice?.type !== 'sidebarInputsChanged') {
|
||||
throw new Error('no change notice')
|
||||
}
|
||||
phone.notice(notice)
|
||||
await phone.settle()
|
||||
expect(Object.keys(phone.view().workspaces)).toContain('local|repo-3::/external')
|
||||
phone.disconnect()
|
||||
})
|
||||
|
||||
it('reads past the shared listing cache and leaves it alone', async () => {
|
||||
const { runtime, rows, listing } = setup()
|
||||
const key = 'local|repo-1::/r/a'
|
||||
|
||||
@@ -9,6 +9,19 @@ import { runtimeVisibleFolderWorkspaces } from '../runtime-visible-catalog'
|
||||
import { WORKTREE_METADATA_DOMAINS } from '../../persistence/loading-store/worktree-identity-metadata'
|
||||
import { ProducerRevision } from './producer-revision'
|
||||
|
||||
// The settings the visibility filter reads: the defaults, and the layouts that classify a checkout.
|
||||
const VISIBILITY_SETTINGS = [
|
||||
'worktreeVisibilityDefaults',
|
||||
'workspaceDir',
|
||||
'nestWorkspaces',
|
||||
'workspaceDirHistory'
|
||||
] as const
|
||||
|
||||
function visibilitySettingsKey(store: RuntimeStore): string {
|
||||
const settings = store.getSettings()
|
||||
return JSON.stringify(VISIBILITY_SETTINGS.map((key) => settings[key] ?? null))
|
||||
}
|
||||
|
||||
export type WorkspaceRowsSources = {
|
||||
store: RuntimeStore | null
|
||||
/** A freshly merged listing, never the shared cache, through worktree.ps's visibility filters. */
|
||||
@@ -25,6 +38,8 @@ export type WorkspaceRowsSources = {
|
||||
*/
|
||||
export class WorkspaceRowsProducer {
|
||||
readonly revision = new ProducerRevision()
|
||||
// What the last snapshot's visibility filter read; null until a snapshot or settings save.
|
||||
private visibilityKey: string | null = null
|
||||
|
||||
constructor(private readonly sources: WorkspaceRowsSources) {
|
||||
sources.onClientEvent(
|
||||
@@ -39,19 +54,28 @@ export class WorkspaceRowsProducer {
|
||||
sources.store?.onStateWritten?.((domains) => {
|
||||
if (
|
||||
domains === undefined ||
|
||||
WORKTREE_METADATA_DOMAINS.some((domain) => domains.includes(domain))
|
||||
WORKTREE_METADATA_DOMAINS.some((domain) => domains.includes(domain)) ||
|
||||
(domains.includes('settings') && this.visibilitySettingsChanged())
|
||||
) {
|
||||
this.revision.bump()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Every settings save reports above, whichever path wrote it and whether or not it notified.
|
||||
private visibilitySettingsChanged(): boolean {
|
||||
const previous = this.visibilityKey
|
||||
this.visibilityKey = this.sources.store ? visibilitySettingsKey(this.sources.store) : null
|
||||
return previous !== null && previous !== this.visibilityKey
|
||||
}
|
||||
|
||||
async snapshot(): Promise<WorkspaceRowsSnapshot> {
|
||||
const { store } = this.sources
|
||||
const revision = this.revision.value
|
||||
if (!store) {
|
||||
return { revision, rows: {} }
|
||||
}
|
||||
this.visibilityKey = visibilitySettingsKey(store)
|
||||
const { worktrees, platformByRepoId } = await this.sources.listVisibleWorktrees()
|
||||
const rows = buildWorkspaceRows({
|
||||
worktrees,
|
||||
|
||||
Reference in New Issue
Block a user