mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
fix(session): adopt every workspace the host partition names, not only tabbed ones
Review caught that gating adoption on `host.tabsByWorktree[key].length > 0` traded the #12721 deletion for a narrower one. The write path routes EVERY worktree-scoped field to the owning partition, so an SSH workspace with open editor files or browser tabs and no terminals had all of it dropped on every restart — and unlike terminal state it cannot be recovered from the host snapshot, which carries terminal fields only, so an unsaved `dirtyDraftContent` was destroyed outright. The defect was not a missing field. It was a hand-maintained field list deciding what the read recovers while the write used the ownership table, so the two could disagree. Adoption now walks `WORKSPACE_SESSION_FIELD_OWNERSHIP` with an exhaustive switch, and a new ownership kind is a compile-time decision rather than a silent omission. Session keys are normalized through the shared `normalizeWorkspaceSessionKeyToWorkspaceId` so host-qualified visit recency (`ssh:target|worktreeId`) reaches its workspace, and the regression is pinned by feeding the shipping split's own output back through the real boot read rather than a hand-built fixture. Co-authored-by: Robert Nisipeanu <github@nisipeanu.com> Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
co-authored by
Robert Nisipeanu
Jinwoo-H
parent
d51df63555
commit
30329e2fe1
@@ -258,7 +258,7 @@
|
||||
|
||||
{
|
||||
"id": "workspace-session.ssh-host-partition-round-trip",
|
||||
"title": "An SSH workspace's tabs are never round-tripped to the host as an empty list",
|
||||
"title": "An SSH workspace round-trips through its own partition without losing tabs, editor files or browser state",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "workspace-session-persistence",
|
||||
@@ -278,8 +278,8 @@
|
||||
"https://github.com/stablyai/orca/issues/18173",
|
||||
"https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts"
|
||||
],
|
||||
"invariant": "A workspace whose tab list exists only in its `ssh:<targetId>` partition is hydrated at boot and published to the host with those tabs. An empty tab row is read as a gap, never as evidence that the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds tabs for is left untouched, and every workspace routes back to the one partition that owns it.",
|
||||
"oracle": "Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:<targetId>` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately hydrate through the real boot read, export and re-import through the real projection, and merge through mergeDirectSshRemoteWorkspaceSession, asserting the tabs survive the publish and the next pull. Assert the reunited workspace routes to `ssh:<targetId>`, that a populated local row is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.",
|
||||
"invariant": "Every workspace the `ssh:<targetId>` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it.",
|
||||
"oracle": "Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:<targetId>` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:<targetId>`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts"
|
||||
],
|
||||
@@ -330,8 +330,8 @@
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.94,
|
||||
"summary": "35 tests passed across 4 files, including the real Store publish and the real export/import/merge round trip."
|
||||
"durationSeconds": 2.45,
|
||||
"summary": "43 tests passed across 4 files, including the real Store publish, the shipping write/read split round trip, and the older-client empty-publish skew direction."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Against pristine main ea102a9eb89 the boot read returned no tabs for the worktree, the export published tabsByWorktreePath[path] = [], the round trip ended with the tabs deleted, and routing answered 'local' instead of ssh:target-1 - 8 of 9 new assertions failed. All pass after the fix. Each assertion was additionally mutation-checked against the specific regression it claims to catch; the load-bearing one, treating an empty tab row as an answer rather than a gap, fails 6."
|
||||
"evidence": "Against pristine main 12f53da542d, 9 of the 11 assertions that target the original defect fail: the boot read returns no tabs, the export publishes an empty tabsByWorktreePath row, the round trip ends with the tabs deleted, routing answers local instead of ssh:target-1, and an older client's empty publish deletes the tabs on pull. The remaining assertions guard the fix itself rather than main's bug: review found that gating adoption on terminal tabs stranded editor-only and browser-only SSH workspaces, destroying unsaved hot-exit drafts no other channel can recover. The gate now discovers a workspace through an exhaustive switch over the field-ownership table, and reintroducing the tabs-only gate fails 6 assertions including the write/read round trip."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
@@ -353,7 +353,8 @@
|
||||
"knownGaps": [
|
||||
"No live SSH host or relay is exercised; the multiplexer is faked at the request boundary.",
|
||||
"Folder workspaces still persist to the local partition, so their half of the writer divergence in #12723 is not covered here.",
|
||||
"Tabs stranded beside an already-populated local row are deliberately not recovered, and no assertion claims they are."
|
||||
"Rows stranded beside a workspace the local partition already holds terminal tabs for are deliberately not recovered, and no assertion claims they are.",
|
||||
"One-shot resurrection in the legacy-transition shape: where an older build left an empty local row while the runtime partition still holds that workspace's tabs, boot adopts them back once. Non-destructive and does not recur, because a workspace this build empties deliberately leaves an empty row in the owning partition, which is not adoptable."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Complete the CI soak requirement with no unexplained flakes.",
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
getWorktreeIdFromHostIdentity,
|
||||
isWorktreeHostIdentity
|
||||
} from '../../../shared/worktree/host-qualified-identity'
|
||||
import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope'
|
||||
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
|
||||
import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner'
|
||||
import {
|
||||
@@ -51,14 +47,9 @@ const WORKTREE_KEYED_FIELDS = (
|
||||
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
|
||||
).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed')
|
||||
|
||||
/** Bare worktree id behind a session key, which may be a WorkspaceKey or a host-qualified identity. */
|
||||
export function normalizeWorkspaceSessionKeyToWorktreeId(value: string): string {
|
||||
if (isWorktreeHostIdentity(value)) {
|
||||
return getWorktreeIdFromHostIdentity(value)
|
||||
}
|
||||
const scope = parseWorkspaceKey(value)
|
||||
return scope?.type === 'worktree' ? scope.worktreeId : value
|
||||
}
|
||||
/** Bare worktree id behind a session key. Lives in shared because the partition adoption read needs
|
||||
* the same normalization, and two implementations of it would drift. */
|
||||
export const normalizeWorkspaceSessionKeyToWorktreeId = normalizeWorkspaceSessionKeyToWorkspaceId
|
||||
|
||||
function resolveClaimedHostId(
|
||||
worktree: WorkspaceRuntimeOwnerProjection,
|
||||
|
||||
@@ -154,6 +154,166 @@ describe('ssh host partition hydration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition workspaces with no terminal tabs', () => {
|
||||
/** An SSH workspace the user left with an editor open and every terminal closed. Orca does not
|
||||
* auto-create a terminal while other tabs exist, so this is an ordinary state — and the whole
|
||||
* workspace now persists to `ssh:<targetId>`, tabs or no tabs. */
|
||||
function editorOnlyPartitions() {
|
||||
return {
|
||||
local: session({ tabsByWorktree: {} }),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [] },
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: '/remote/checkout/feature/src/main.ts',
|
||||
relativePath: 'src/main.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript',
|
||||
dirtyDraftContent: 'unsaved work'
|
||||
}
|
||||
]
|
||||
},
|
||||
activeFileIdByWorktree: { [WORKTREE_ID]: '/remote/checkout/feature/src/main.ts' },
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' },
|
||||
browserTabsByWorktree: {
|
||||
[WORKTREE_ID]: [{ id: 'browser-1', name: 'Docs', tabs: [], activeTabId: null }]
|
||||
},
|
||||
lastVisitedAtByWorktreeId: { [`${SSH_HOST_ID}|${WORKTREE_ID}`]: 4242 }
|
||||
} as unknown as WorkspaceSessionState)
|
||||
}
|
||||
}
|
||||
|
||||
it('restores the open editor files', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(
|
||||
read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => file.relativePath)
|
||||
).toEqual(['src/main.ts'])
|
||||
})
|
||||
|
||||
it('restores an unsaved hot-exit draft, which no other channel can recover', async () => {
|
||||
// RemoteWorkspaceSession carries terminal fields only, so the SSH host snapshot cannot
|
||||
// round-trip editor state. Losing it here loses user-authored content outright.
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'unsaved work'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores browser workspaces and the active tab type', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.browserTabsByWorktree?.[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'browser-1'
|
||||
])
|
||||
expect(read.session.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor')
|
||||
})
|
||||
|
||||
it('restores a workspace the host partition names with no tabs row at all', async () => {
|
||||
// Stricter than the fixtures above, which carry an empty `tabsByWorktree` key. A workspace that
|
||||
// never had a terminal has no such key, so tab presence cannot be what discovers it.
|
||||
const partitions = {
|
||||
local: session({ tabsByWorktree: {} }),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: {},
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: '/remote/checkout/feature/README.md',
|
||||
relativePath: 'README.md',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'markdown',
|
||||
dirtyDraftContent: 'never saved'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState)
|
||||
}
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'never saved'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores host-qualified visit recency, which is keyed by host and not by bare id', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.lastVisitedAtByWorktreeId?.[`${SSH_HOST_ID}|${WORKTREE_ID}`]).toBe(4242)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition write/read round trip', () => {
|
||||
/** The two halves pinned together through the shipping write path. Testing the read against a
|
||||
* hand-built partition is what let an editor-only workspace fall out: the fixture asserted the
|
||||
* shape the read expected instead of the shape the write actually produces. */
|
||||
async function roundTrip(payload: WorkspaceSessionState): Promise<WorkspaceSessionState> {
|
||||
const { buildWorkspaceSessionHostSnapshots } =
|
||||
await import('./workspace-session-host-persistence')
|
||||
const snapshots = buildWorkspaceSessionHostSnapshots(payload, {
|
||||
repos: [{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: null }],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
const partitions: Record<string, WorkspaceSessionState> = {}
|
||||
for (const snapshot of snapshots) {
|
||||
partitions[snapshot.hostId ?? 'local'] = snapshot.state
|
||||
}
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(partitions as never),
|
||||
repos
|
||||
)
|
||||
return read.session
|
||||
}
|
||||
|
||||
it('sends an editor-only SSH workspace to its partition and reads it back', async () => {
|
||||
const restored = await roundTrip(
|
||||
session({
|
||||
tabsByWorktree: {},
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: '/remote/checkout/feature/src/app.ts',
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript',
|
||||
dirtyDraftContent: 'work in progress'
|
||||
}
|
||||
]
|
||||
},
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' }
|
||||
} as unknown as WorkspaceSessionState)
|
||||
)
|
||||
|
||||
expect(restored.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'work in progress'
|
||||
)
|
||||
expect(restored.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor')
|
||||
})
|
||||
|
||||
it('sends a terminal SSH workspace to its partition and reads it back', async () => {
|
||||
const restored = await roundTrip(
|
||||
session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-live')] } })
|
||||
)
|
||||
|
||||
expect(restored.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition remote-workspace round trip', () => {
|
||||
it('does not delete the worktree tabs across a publish and the next pull', async () => {
|
||||
const partitions = strandedPartitions([tab('tab-runtime')])
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { WorkspaceKey, WorkspaceScope } from './folder-workspace-types'
|
||||
import {
|
||||
getWorktreeIdFromHostIdentity,
|
||||
isWorktreeHostIdentity
|
||||
} from './worktree/host-qualified-identity'
|
||||
|
||||
export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey {
|
||||
return `worktree:${worktreeId}`
|
||||
@@ -20,6 +24,16 @@ export function parseWorkspaceKey(value: string): WorkspaceScope | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Bare workspace id behind a session key, which may be a WorkspaceKey, a host-qualified identity
|
||||
* (`ssh:target|repo::path`, used by visit recency), or already a bare id. */
|
||||
export function normalizeWorkspaceSessionKeyToWorkspaceId(value: string): string {
|
||||
if (isWorktreeHostIdentity(value)) {
|
||||
return getWorktreeIdFromHostIdentity(value)
|
||||
}
|
||||
const scope = parseWorkspaceKey(value)
|
||||
return scope?.type === 'worktree' ? scope.worktreeId : value
|
||||
}
|
||||
|
||||
export function isWorkspaceKey(value: string): value is WorkspaceKey {
|
||||
return parseWorkspaceKey(value) !== null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { WorkspaceSessionState } from './workspace-session-state-types'
|
||||
import type { TerminalTab } from './terminal-tab-types'
|
||||
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-field-ownership'
|
||||
import {
|
||||
WORKSPACE_SESSION_FIELD_OWNERSHIP,
|
||||
type WorkspaceSessionFieldOwnership
|
||||
} from './workspace-session-host-field-ownership'
|
||||
import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope'
|
||||
|
||||
/**
|
||||
* Fold rows a host partition holds alone back into the session the readers assemble.
|
||||
@@ -10,66 +13,133 @@ import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-fiel
|
||||
* now names a single owner, but both stores still hold real data, so every reader has to reunite
|
||||
* them once before the write path returns the result to that owner.
|
||||
*
|
||||
* Strictly gap-filling. A workspace the base holds no tabs for takes the host partition's rows; a
|
||||
* workspace the base does hold tabs for keeps them untouched, and the host partition adds nothing.
|
||||
* **A workspace is adopted whenever the host partition names it at all — not only when it has
|
||||
* terminal tabs.** The write path routes EVERY worktree-scoped field to the owning partition, so a
|
||||
* workspace with open editor files, browser tabs or tab groups and no terminals lives there just as
|
||||
* completely as one with terminals. Gating on tabs would strand exactly those, and unlike terminal
|
||||
* state they cannot be recovered from the SSH host snapshot, which carries terminal fields only —
|
||||
* an unsaved `dirtyDraftContent` would be destroyed outright.
|
||||
*
|
||||
* Why an EMPTY tab row counts as a gap and not as an answer: an empty list is not evidence that
|
||||
* anything was closed. `mergeDirectSshRemoteWorkspaceSession` already argues this at length, and
|
||||
* That is why the walk below switches exhaustively over `WORKSPACE_SESSION_FIELD_OWNERSHIP` instead
|
||||
* of listing the fields it knows about: a hand-maintained list is what let editor and browser state
|
||||
* fall out, and a new ownership kind must not be able to fall out the same way.
|
||||
*
|
||||
* The one thing the base keeps unconditionally is a workspace it holds **terminal tabs** for. That
|
||||
* is the live copy the user is looking at, and merging a stale partition into it would re-add tabs
|
||||
* they had closed on every launch. Leaving it alone keeps this a one-shot repair, at the cost of
|
||||
* not recovering rows stranded beside a populated workspace — which are stranded on main today too,
|
||||
* so it is never a new loss. An EMPTY tab row is not such a copy: an empty list is not evidence
|
||||
* that anything was closed (`mergeDirectSshRemoteWorkspaceSession` argues this at length, and
|
||||
* docs/reference/ssh-execution-boundary.md makes it general — "we could not see it" is
|
||||
* `unverifiable`, never proof of absence. Treating that empty row as the truth is exactly what
|
||||
* published an empty tab list and let `replace-session` delete the host's copy (#12721).
|
||||
*
|
||||
* Why nothing is merged INTO a populated workspace: the two lists would have to be unioned by tab
|
||||
* id, and a stale row in the unread partition would then re-add tabs the user had closed, on every
|
||||
* launch. Leaving a populated workspace alone keeps this a one-shot repair — afterwards the
|
||||
* workspace lives in one partition — at the cost of not recovering tabs stranded beside a
|
||||
* populated row. Those are stranded on main today too, so that is never a new loss.
|
||||
* `unverifiable`, never proof of absence). Treating it as the truth is what published an empty tab
|
||||
* list and let `replace-session` delete the host's copy (#12721).
|
||||
*/
|
||||
|
||||
type KeyedRecord = Record<string, unknown>
|
||||
|
||||
const WORKSPACE_KEYED_FIELDS = (
|
||||
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
|
||||
).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed')
|
||||
|
||||
/** Keyed by a tab id, or by a pane key that starts with one, so an adopted workspace's rows can be
|
||||
* recognised by the tabs it brought. */
|
||||
const TAB_SCOPED_FIELDS = (
|
||||
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
|
||||
).filter((field) => {
|
||||
const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
|
||||
return ownership === 'tabKeyed' || ownership === 'paneKeyed'
|
||||
})
|
||||
|
||||
/** Keyed opaquely, but each record names the workspace it belongs to — the only routing left once
|
||||
* the tab or pane it describes is gone, and the same one `splitWorkspaceSessionByHost` uses. */
|
||||
const SELF_DESCRIBING_FIELDS = (
|
||||
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
|
||||
).filter((field) => {
|
||||
const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
|
||||
return ownership === 'sleepingAgentKeyed' || ownership === 'surfaceTombstoneKeyed'
|
||||
})
|
||||
|
||||
const WORKSPACE_ARRAY_FIELDS = (
|
||||
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
|
||||
).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeArray')
|
||||
const SESSION_FIELDS = Object.keys(
|
||||
WORKSPACE_SESSION_FIELD_OWNERSHIP
|
||||
) as (keyof WorkspaceSessionState)[]
|
||||
|
||||
function asRecord(value: unknown): KeyedRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as KeyedRecord) : null
|
||||
}
|
||||
|
||||
/** Workspaces the host partition is the only side holding tabs for. */
|
||||
function strandedWorkspaceKeys(
|
||||
function recordWorkspaceId(entry: unknown): string | null {
|
||||
const worktreeId = asRecord(entry)?.worktreeId
|
||||
return typeof worktreeId === 'string' ? worktreeId : null
|
||||
}
|
||||
|
||||
/** A browser-workspace row is keyed by browser workspace id; its pages name the workspace. */
|
||||
function browserPagesWorkspaceId(entry: unknown): string | null {
|
||||
const first = Array.isArray(entry) ? (entry[0] as unknown) : null
|
||||
return recordWorkspaceId(first)
|
||||
}
|
||||
|
||||
/** Workspaces the base holds terminal tabs for: its live copies, which adoption never touches. */
|
||||
function workspacesTheBaseOwns(base: WorkspaceSessionState): Set<string> {
|
||||
const owned = new Set<string>()
|
||||
for (const [key, tabs] of Object.entries(base.tabsByWorktree ?? {})) {
|
||||
if (Array.isArray(tabs) && tabs.length > 0) {
|
||||
owned.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
|
||||
}
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
/** Every workspace the host partition names in any scoped field, minus the base's live copies. */
|
||||
function adoptableWorkspaceIds(
|
||||
base: WorkspaceSessionState,
|
||||
host: WorkspaceSessionState
|
||||
): Set<string> {
|
||||
const stranded = new Set<string>()
|
||||
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
|
||||
if (Array.isArray(tabs) && tabs.length > 0 && (base.tabsByWorktree?.[key]?.length ?? 0) === 0) {
|
||||
stranded.add(key)
|
||||
const owned = workspacesTheBaseOwns(base)
|
||||
const adoptable = new Set<string>()
|
||||
const consider = (value: string | null | undefined): void => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
const workspaceId = normalizeWorkspaceSessionKeyToWorkspaceId(value)
|
||||
if (!owned.has(workspaceId)) {
|
||||
adoptable.add(workspaceId)
|
||||
}
|
||||
}
|
||||
return stranded
|
||||
for (const field of SESSION_FIELDS) {
|
||||
const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
|
||||
const value = host[field]
|
||||
switch (ownership) {
|
||||
case 'global':
|
||||
case 'hostPrivate':
|
||||
case 'tabKeyed':
|
||||
case 'paneKeyed':
|
||||
case 'fileKeyed':
|
||||
// Keyed by something the workspaces below already account for.
|
||||
break
|
||||
case 'worktreeKeyed':
|
||||
for (const key of Object.keys(asRecord(value) ?? {})) {
|
||||
consider(key)
|
||||
}
|
||||
break
|
||||
case 'worktreeArray':
|
||||
for (const id of Array.isArray(value) ? (value as string[]) : []) {
|
||||
consider(id)
|
||||
}
|
||||
break
|
||||
case 'sleepingAgentKeyed':
|
||||
case 'surfaceTombstoneKeyed':
|
||||
for (const entry of Object.values(asRecord(value) ?? {})) {
|
||||
consider(recordWorkspaceId(entry))
|
||||
}
|
||||
break
|
||||
case 'browserWorkspaceKeyed':
|
||||
for (const entry of Object.values(asRecord(value) ?? {})) {
|
||||
consider(browserPagesWorkspaceId(entry))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return adoptable
|
||||
}
|
||||
|
||||
function adoptRecord(
|
||||
next: WorkspaceSessionState,
|
||||
host: WorkspaceSessionState,
|
||||
field: keyof WorkspaceSessionState,
|
||||
shouldAdopt: (key: string, entry: unknown) => boolean,
|
||||
/** Adoptable workspaces are host-owned, so their rows replace the base's leftovers; everything
|
||||
* else only fills a gap, so nothing the base already answered is overwritten. */
|
||||
replace: boolean
|
||||
): void {
|
||||
const hostRecord = asRecord(host[field])
|
||||
if (!hostRecord) {
|
||||
return
|
||||
}
|
||||
const merged = { ...asRecord(next[field]) }
|
||||
for (const [key, entry] of Object.entries(hostRecord)) {
|
||||
if (shouldAdopt(key, entry) && (replace || !Object.hasOwn(merged, key))) {
|
||||
merged[key] = entry
|
||||
}
|
||||
}
|
||||
;(next as KeyedRecord)[field] = merged
|
||||
}
|
||||
|
||||
export function adoptStrandedHostPartitionSession(
|
||||
@@ -79,84 +149,106 @@ export function adoptStrandedHostPartitionSession(
|
||||
if (!host) {
|
||||
return base
|
||||
}
|
||||
const stranded = strandedWorkspaceKeys(base, host)
|
||||
if (stranded.size === 0) {
|
||||
const adoptable = adoptableWorkspaceIds(base, host)
|
||||
if (adoptable.size === 0) {
|
||||
return base
|
||||
}
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = { ...base.tabsByWorktree }
|
||||
for (const key of stranded) {
|
||||
tabsByWorktree[key] = host.tabsByWorktree[key] ?? []
|
||||
const adopts = (key: string): boolean =>
|
||||
adoptable.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))
|
||||
|
||||
const next: WorkspaceSessionState = { ...base, tabsByWorktree: { ...base.tabsByWorktree } }
|
||||
const adoptedTabIds = new Set<string>()
|
||||
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
|
||||
if (!adopts(key) || !Array.isArray(tabs)) {
|
||||
continue
|
||||
}
|
||||
next.tabsByWorktree[key] = tabs
|
||||
for (const tab of tabs) {
|
||||
adoptedTabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
const next: WorkspaceSessionState = { ...base, tabsByWorktree }
|
||||
for (const field of WORKSPACE_KEYED_FIELDS) {
|
||||
if (field === 'tabsByWorktree') {
|
||||
// Computed up front rather than as the walk passes `openFilesByWorktree`, so the file-keyed
|
||||
// fields do not depend on the ownership table's declaration order.
|
||||
const adoptedFileIds = new Set<string>()
|
||||
for (const [key, files] of Object.entries(asRecord(host.openFilesByWorktree) ?? {})) {
|
||||
if (!adopts(key)) {
|
||||
continue
|
||||
}
|
||||
const hostRecord = asRecord(host[field])
|
||||
if (!hostRecord) {
|
||||
continue
|
||||
}
|
||||
// A stranded workspace's other rows describe the tabs just adopted, so they replace the base's
|
||||
// leftovers rather than filling around them.
|
||||
const merged = { ...asRecord(next[field]) }
|
||||
for (const key of stranded) {
|
||||
if (Object.hasOwn(hostRecord, key)) {
|
||||
merged[key] = hostRecord[key]
|
||||
for (const file of Array.isArray(files) ? files : []) {
|
||||
const filePath = asRecord(file)?.filePath
|
||||
if (typeof filePath === 'string') {
|
||||
adoptedFileIds.add(filePath)
|
||||
}
|
||||
}
|
||||
;(next as KeyedRecord)[field] = merged
|
||||
}
|
||||
const adoptedTabIds = new Set(
|
||||
[...stranded].flatMap((key) => (host.tabsByWorktree[key] ?? []).map((tab) => tab.id))
|
||||
)
|
||||
for (const field of TAB_SCOPED_FIELDS) {
|
||||
const hostRecord = asRecord(host[field])
|
||||
if (!hostRecord) {
|
||||
continue
|
||||
}
|
||||
const merged = { ...asRecord(next[field]) }
|
||||
for (const [key, entry] of Object.entries(hostRecord)) {
|
||||
// Scoped to the adopted tabs so a tab the base already answered for keeps its own rows.
|
||||
if (!Object.hasOwn(merged, key) && adoptedTabIds.has(key.split(':', 1)[0] ?? '')) {
|
||||
merged[key] = entry
|
||||
|
||||
for (const field of SESSION_FIELDS) {
|
||||
const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
|
||||
switch (ownership) {
|
||||
case 'global':
|
||||
case 'hostPrivate':
|
||||
// 'local' owns the globals; hostPrivate is main's own per-partition fence.
|
||||
break
|
||||
case 'worktreeKeyed':
|
||||
if (field !== 'tabsByWorktree') {
|
||||
adoptRecord(next, host, field, (key) => adopts(key), true)
|
||||
}
|
||||
break
|
||||
case 'worktreeArray': {
|
||||
const hostIds = host[field]
|
||||
const adopted = (Array.isArray(hostIds) ? (hostIds as string[]) : []).filter(adopts)
|
||||
if (adopted.length > 0) {
|
||||
const baseIds = next[field]
|
||||
;(next as KeyedRecord)[field] = [
|
||||
...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted])
|
||||
]
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tabKeyed':
|
||||
case 'paneKeyed':
|
||||
// Keyed by a tab id, or by a pane key that starts with one. Tab ids are colon-free, so the
|
||||
// first segment identifies the owning tab in both shapes.
|
||||
adoptRecord(
|
||||
next,
|
||||
host,
|
||||
field,
|
||||
(key) => adoptedTabIds.has(key.split(':', 1)[0] ?? ''),
|
||||
false
|
||||
)
|
||||
break
|
||||
case 'sleepingAgentKeyed':
|
||||
case 'surfaceTombstoneKeyed':
|
||||
// Keyed opaquely, but each record names its own workspace — the only routing left once the
|
||||
// tab or pane it describes is gone, and the same one `splitWorkspaceSessionByHost` uses.
|
||||
adoptRecord(
|
||||
next,
|
||||
host,
|
||||
field,
|
||||
(_key, entry) => {
|
||||
const workspaceId = recordWorkspaceId(entry)
|
||||
return workspaceId !== null && adopts(workspaceId)
|
||||
},
|
||||
false
|
||||
)
|
||||
break
|
||||
case 'browserWorkspaceKeyed':
|
||||
adoptRecord(
|
||||
next,
|
||||
host,
|
||||
field,
|
||||
(_key, entry) => {
|
||||
const workspaceId = browserPagesWorkspaceId(entry)
|
||||
return workspaceId !== null && adopts(workspaceId)
|
||||
},
|
||||
false
|
||||
)
|
||||
break
|
||||
case 'fileKeyed':
|
||||
// Routed by the open file's workspace, so it follows the files adopted just above.
|
||||
adoptRecord(next, host, field, (key) => adoptedFileIds.has(key), false)
|
||||
break
|
||||
}
|
||||
;(next as KeyedRecord)[field] = merged
|
||||
}
|
||||
for (const field of SELF_DESCRIBING_FIELDS) {
|
||||
const hostRecord = asRecord(host[field])
|
||||
if (!hostRecord) {
|
||||
continue
|
||||
}
|
||||
const merged = { ...asRecord(next[field]) }
|
||||
for (const [key, entry] of Object.entries(hostRecord)) {
|
||||
// Why these travel at all: a hibernated agent or a surface tombstone for a stranded workspace
|
||||
// is only in the host partition, and the renderer's next full write replaces that partition —
|
||||
// so a record the reunited session never carried would be dropped by the repair itself.
|
||||
const worktreeId = asRecord(entry)?.worktreeId
|
||||
if (
|
||||
!Object.hasOwn(merged, key) &&
|
||||
typeof worktreeId === 'string' &&
|
||||
stranded.has(worktreeId)
|
||||
) {
|
||||
merged[key] = entry
|
||||
}
|
||||
}
|
||||
;(next as KeyedRecord)[field] = merged
|
||||
}
|
||||
for (const field of WORKSPACE_ARRAY_FIELDS) {
|
||||
const hostIds = host[field]
|
||||
if (!Array.isArray(hostIds)) {
|
||||
continue
|
||||
}
|
||||
const adopted = (hostIds as string[]).filter((id) => stranded.has(id))
|
||||
if (adopted.length === 0) {
|
||||
continue
|
||||
}
|
||||
const baseIds = next[field]
|
||||
;(next as KeyedRecord)[field] = [
|
||||
...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted])
|
||||
]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user