mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(session): clear an emptied field on a runtime partition this window loaded (#33972)
* fix(session): clear an emptied field on a runtime partition this window loaded * fix(session): leave hosts with parked contested rows to the shadow Also replaces the new type assertions with checked types.
This commit is contained in:
@@ -84,7 +84,8 @@ export async function bootWindowFromPartitions(
|
||||
...options,
|
||||
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey,
|
||||
contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions,
|
||||
contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey
|
||||
contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey,
|
||||
hydratedRuntimeSessionHostIds: sessionRead.hydratedRuntimeSessionHostIds
|
||||
})
|
||||
actions.hydrateTabsSession(sessionRead.session, options)
|
||||
actions.hydrateEditorSession(sessionRead.session, options)
|
||||
|
||||
@@ -181,11 +181,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session)
|
||||
}
|
||||
timeRendererStartupSyncStep('hydrate-session-stores', () => {
|
||||
// The read's host-ownership fields are named as the hydration options it feeds.
|
||||
actions.hydrateWorkspaceSession(sessionRead.session, {
|
||||
...sessionHydrationOptions,
|
||||
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey,
|
||||
contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions,
|
||||
contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey
|
||||
...sessionRead,
|
||||
...sessionHydrationOptions
|
||||
})
|
||||
actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions)
|
||||
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-state-types'
|
||||
import {
|
||||
patchWorkspaceSessionByHost,
|
||||
type HostPersistenceState
|
||||
} from './workspace-session-host-persistence'
|
||||
|
||||
const RUNTIME = toRuntimeExecutionHostId('env-1')
|
||||
const WT = 'repo-remote::/srv/project'
|
||||
|
||||
// #23914: main merges a patch fieldwise, so a runtime partition the patch omits keeps a closed tab.
|
||||
async function patchAfterLastBrowserTabCloses(
|
||||
state: Partial<HostPersistenceState>
|
||||
): Promise<Record<string, Record<string, unknown>>> {
|
||||
const partitions: Record<string, Record<string, unknown>> = {
|
||||
local: { browserTabsByWorktree: {}, browserPagesByWorkspace: {} },
|
||||
[RUNTIME]: {
|
||||
browserTabsByWorktree: { [WT]: [{ id: 'bw-1', worktreeId: WT }] },
|
||||
browserPagesByWorkspace: { 'bw-1': [{ id: 'p-1', workspaceId: 'bw-1' }] }
|
||||
}
|
||||
}
|
||||
await patchWorkspaceSessionByHost(
|
||||
{
|
||||
get: async () => getDefaultWorkspaceSession(),
|
||||
patch: async (patch: WorkspaceSessionPatch, hostId?: ExecutionHostId) => {
|
||||
const key = hostId ?? 'local'
|
||||
partitions[key] = { ...partitions[key], ...patch }
|
||||
}
|
||||
},
|
||||
{ browserTabsByWorktree: {}, browserPagesByWorkspace: {} },
|
||||
{
|
||||
repos: [{ id: 'repo-remote', connectionId: null, executionHostId: RUNTIME }],
|
||||
worktreesByRepo: {},
|
||||
tabsByWorktree: {},
|
||||
...state
|
||||
}
|
||||
).written
|
||||
return partitions
|
||||
}
|
||||
|
||||
describe('a patch that empties a field on a runtime host', () => {
|
||||
it('clears the closed tab from a runtime partition this window loaded', async () => {
|
||||
const partitions = await patchAfterLastBrowserTabCloses({
|
||||
hydratedRuntimeSessionHostIds: [RUNTIME]
|
||||
})
|
||||
expect(partitions[RUNTIME]?.browserTabsByWorktree).toEqual({})
|
||||
expect(partitions[RUNTIME]?.browserPagesByWorkspace).toEqual({})
|
||||
})
|
||||
|
||||
it('leaves a runtime partition this window never loaded, or holds parked rows for', async () => {
|
||||
const unread = await patchAfterLastBrowserTabCloses({})
|
||||
expect(unread[RUNTIME]?.browserTabsByWorktree).toEqual({ [WT]: [expect.anything()] })
|
||||
|
||||
const parked = await patchAfterLastBrowserTabCloses({
|
||||
hydratedRuntimeSessionHostIds: [RUNTIME],
|
||||
contestedHostWorkspaceSessions: { [RUNTIME]: getDefaultWorkspaceSession() }
|
||||
})
|
||||
expect(parked[RUNTIME]?.browserPagesByWorkspace).toEqual({
|
||||
'bw-1': [expect.anything()]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,8 @@ export type WorkspaceSessionHostRead = {
|
||||
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
|
||||
contestedHostWorkspaceSessions: HostSessionSlices
|
||||
contestedPrimaryHostBySessionKey: Record<string, ExecutionHostId>
|
||||
/** Runtime partitions this read actually loaded, so a later write may speak for all of their rows. */
|
||||
hydratedRuntimeSessionHostIds: ExecutionHostId[]
|
||||
}
|
||||
|
||||
const WORKSPACE_SESSION_KEYED_FIELDS = [
|
||||
@@ -223,7 +225,8 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
// Derive owners from surviving merged rows, never parked raw rows, or startup creates runtime placeholders for local winners.
|
||||
runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(merged.slices),
|
||||
contestedHostWorkspaceSessions: shadow,
|
||||
contestedPrimaryHostBySessionKey: primaryHostBySessionKey
|
||||
contestedPrimaryHostBySessionKey: primaryHostBySessionKey,
|
||||
hydratedRuntimeSessionHostIds: [...runtimeHostIds].filter((hostId) => slices[hostId])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export type HostPersistenceState = WorkspaceTabOwnerCatalog & {
|
||||
}[]
|
||||
worktreesByRepo: Record<string, readonly WorkspaceRuntimeOwnerProjection[]>
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
|
||||
hydratedRuntimeSessionHostIds?: readonly ExecutionHostId[]
|
||||
/** Entries a co-claimant host lost to the primary of a contested workspace id; written straight
|
||||
* back to their own partition so the primary's write cannot erase them. */
|
||||
contestedHostWorkspaceSessions?: HostSessionSlices
|
||||
@@ -201,10 +202,22 @@ function splitWorkspaceSessionForWrite(
|
||||
mode: HostSessionWriteMode
|
||||
): HostSessionSlices {
|
||||
const routing = buildHostSessionRouting(state)
|
||||
const catalogRuntimeHostIds = new Set(state.repos.map((repo) => getRepoExecutionHostId(repo)))
|
||||
// A patch replaces fields fieldwise, so a host whose last entry went needs the emptied field. Only a
|
||||
// partition this window loaded and still catalogs is one its state speaks for in full.
|
||||
// The contested shadow restores only worktree-keyed rows, so a host holding parked rows keeps today's rule.
|
||||
const emptyContainerHostIds =
|
||||
mode === 'patch'
|
||||
? (state.hydratedRuntimeSessionHostIds ?? []).filter(
|
||||
(hostId) =>
|
||||
catalogRuntimeHostIds.has(hostId) && !state.contestedHostWorkspaceSessions?.[hostId]
|
||||
)
|
||||
: []
|
||||
// Use live tab catalogs for layouts-only patches; payload-only routing loses remote park scrollback in local pruning (#21295).
|
||||
const worktreeIdByTabId = extendWorktreeIdByTabId(buildWorktreeIdByTabId(payload), state)
|
||||
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId, {
|
||||
worktreeIdByTabId
|
||||
worktreeIdByTabId,
|
||||
emptyContainerHostIds
|
||||
})
|
||||
attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode)
|
||||
return slices
|
||||
|
||||
@@ -129,7 +129,12 @@ function assignKeyedByResolvedWorktree(
|
||||
export function splitWorkspaceSessionByHost(
|
||||
state: WorkspaceSessionState,
|
||||
hostIdByWorktreeId: HostIdByWorktreeId,
|
||||
options: { worktreeIdByTabId?: Map<string, string> } = {}
|
||||
options: {
|
||||
worktreeIdByTabId?: Map<string, string>
|
||||
/** Hosts whose rows this write speaks for in full: a field it carries replaces theirs even when
|
||||
* none of their workspaces has an entry left, or main's fieldwise patch keeps the stale rows (#23914). */
|
||||
emptyContainerHostIds?: readonly ExecutionHostId[]
|
||||
} = {}
|
||||
): HostSessionSlices {
|
||||
// Copy only present global own-keys: partial patches must not inject undefined over absent fields; explicit undefined remains intentional.
|
||||
const template = {} as WorkspaceSessionState
|
||||
@@ -167,6 +172,10 @@ export function splitWorkspaceSessionByHost(
|
||||
// Seed explicit empty containers locally so merge preserves their presence.
|
||||
if (ownership !== 'global' && ownership !== 'hostPrivate') {
|
||||
localSlice[field] ??= Array.isArray(value) ? [] : {}
|
||||
for (const hostId of options.emptyContainerHostIds ?? []) {
|
||||
const slice = ensureSlice(slices, hostId, templates)
|
||||
Object.assign(slice, { [field]: slice[field] ?? (Array.isArray(value) ? [] : {}) })
|
||||
}
|
||||
}
|
||||
switch (ownership) {
|
||||
case 'global':
|
||||
|
||||
@@ -61,6 +61,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
contestedHostWorkspaceSessions: {},
|
||||
contestedPrimaryHostBySessionKey: {},
|
||||
hydratedRuntimeSessionHostIds: [],
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
closedTerminalTabTombstonesByTabId: {},
|
||||
hydrationSucceeded: false,
|
||||
|
||||
@@ -32,4 +32,6 @@ export type HydrateWorkspaceSessionOptions = {
|
||||
contestedHostWorkspaceSessions?: HostSessionSlices
|
||||
/** Partition each restored session key was read from; omitted leaves the store's copy. */
|
||||
contestedPrimaryHostBySessionKey?: Record<string, ExecutionHostId>
|
||||
/** Runtime partitions the cold read loaded; omitted leaves the store's copy. */
|
||||
hydratedRuntimeSessionHostIds?: ExecutionHostId[]
|
||||
} & WorkspaceSessionHydrationOptions
|
||||
|
||||
@@ -99,6 +99,8 @@ export type TerminalState = {
|
||||
contestedHostWorkspaceSessions: HostSessionSlices
|
||||
/** Partition each restored session key was read from, so a write returns its rows there. */
|
||||
contestedPrimaryHostBySessionKey: Record<string, ExecutionHostId>
|
||||
/** Runtime partitions the startup read loaded; only these may receive emptied host-keyed fields. */
|
||||
hydratedRuntimeSessionHostIds: ExecutionHostId[]
|
||||
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
|
||||
closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId
|
||||
hydrationSucceeded: boolean
|
||||
|
||||
@@ -35,7 +35,14 @@ export type WorkspaceHydrationPatch = Pick<
|
||||
| 'localOnlyScrollbackByTabId'
|
||||
> &
|
||||
// Only cold reads carry contested-host shadows; scoped hydration must preserve the store’s copy.
|
||||
Partial<Pick<AppState, 'contestedHostWorkspaceSessions' | 'contestedPrimaryHostBySessionKey'>>
|
||||
Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
| 'contestedHostWorkspaceSessions'
|
||||
| 'contestedPrimaryHostBySessionKey'
|
||||
| 'hydratedRuntimeSessionHostIds'
|
||||
>
|
||||
>
|
||||
|
||||
export function replaceHydratedRecordKeys<T>(
|
||||
current: Record<string, T>,
|
||||
|
||||
@@ -161,6 +161,9 @@ export function createWorkspaceTerminalHydrationActions(
|
||||
...(options?.contestedPrimaryHostBySessionKey
|
||||
? { contestedPrimaryHostBySessionKey: options.contestedPrimaryHostBySessionKey }
|
||||
: {}),
|
||||
...(options?.hydratedRuntimeSessionHostIds
|
||||
? { hydratedRuntimeSessionHostIds: options.hydratedRuntimeSessionHostIds }
|
||||
: {}),
|
||||
repos: runtimeSessionPlaceholders.repos,
|
||||
tabsByWorktree,
|
||||
worktreesByRepo,
|
||||
|
||||
Reference in New Issue
Block a user